From 3a18fd8128ac53a744c9313022a4f225a848bf9a Mon Sep 17 00:00:00 2001 From: skwasjer Date: Wed, 4 May 2022 16:05:37 +0200 Subject: [PATCH 1/6] feat: add fluent response builder API --- .../Extensions/IRespondsExtensions.cs | 69 ++-- .../Extensions/ResponseBuilderExtensions.cs | 311 ++++++++++++++++++ src/MockHttp/IResponseBuilder.cs | 16 + src/MockHttp/ResponseBuilder.cs | 44 +++ src/MockHttp/Responses/FromStreamStrategy.cs | 38 --- src/MockHttp/Responses/HttpContentBehavior.cs | 20 ++ src/MockHttp/Responses/HttpHeaderBehavior.cs | 54 +++ src/MockHttp/Responses/IResponseBehavior.cs | 25 ++ src/MockHttp/Responses/StatusCodeBehavior.cs | 27 ++ ...{TimeoutStrategy.cs => TimeoutBehavior.cs} | 13 +- ...rategyTests.cs => TimeoutBehaviorTests.cs} | 17 +- 11 files changed, 562 insertions(+), 72 deletions(-) create mode 100644 src/MockHttp/Extensions/ResponseBuilderExtensions.cs create mode 100644 src/MockHttp/IResponseBuilder.cs create mode 100644 src/MockHttp/ResponseBuilder.cs delete mode 100644 src/MockHttp/Responses/FromStreamStrategy.cs create mode 100644 src/MockHttp/Responses/HttpContentBehavior.cs create mode 100644 src/MockHttp/Responses/HttpHeaderBehavior.cs create mode 100644 src/MockHttp/Responses/IResponseBehavior.cs create mode 100644 src/MockHttp/Responses/StatusCodeBehavior.cs rename src/MockHttp/Responses/{TimeoutStrategy.cs => TimeoutBehavior.cs} (64%) rename test/MockHttp.Tests/Responses/{TimeoutStrategyTests.cs => TimeoutBehaviorTests.cs} (69%) diff --git a/src/MockHttp/Extensions/IRespondsExtensions.cs b/src/MockHttp/Extensions/IRespondsExtensions.cs index 47b100b4..b5155d59 100644 --- a/src/MockHttp/Extensions/IRespondsExtensions.cs +++ b/src/MockHttp/Extensions/IRespondsExtensions.cs @@ -79,7 +79,7 @@ internal static TResult RespondUsing(this IResponds public static TResult Respond(this IResponds responds, HttpStatusCode statusCode) where TResult : IResponseResult { - return responds.Respond(() => new HttpResponseMessage(statusCode)); + return responds.Respond(with => with.StatusCode(statusCode)); } /// @@ -157,13 +157,11 @@ public static TResult Respond(this IResponds responds, HttpSta throw new ArgumentNullException(nameof(content)); } - return responds.Respond(() => new HttpResponseMessage(statusCode) - { - Content = new StringContent(content) - { - Headers = { ContentType = mediaType } - } - }); + return responds.Respond(with => with + .StatusCode(statusCode) + .Body(content) + .ContentType(mediaType ?? ResponseBuilder.DefaultMediaType) + ); } /// @@ -190,7 +188,7 @@ public static TResult Respond(this IResponds responds, string public static TResult Respond(this IResponds responds, HttpStatusCode statusCode, string content, Encoding encoding, string mediaType) where TResult : IResponseResult { - return responds.Respond(statusCode, content, new MediaTypeHeaderValue(mediaType ?? "text/plain") { CharSet = (encoding ?? Encoding.UTF8).WebName }); + return responds.Respond(statusCode, content, new MediaTypeHeaderValue(mediaType ?? ResponseBuilder.DefaultMediaType.MediaType) { CharSet = (encoding ?? ResponseBuilder.DefaultWebEncoding).WebName }); } /// @@ -273,14 +271,11 @@ public static TResult Respond(this IResponds responds, HttpSta throw new ArgumentException("Cannot read from stream.", nameof(streamContent)); } - byte[] buffer; - using (var ms = new MemoryStream()) - { - streamContent.CopyTo(ms); - buffer = ms.ToArray(); - } - - return responds.Respond(statusCode, () => new MemoryStream(buffer), mediaType); + return responds.Respond(with => with + .StatusCode(statusCode) + .Body(streamContent) + .ContentType(mediaType ?? ResponseBuilder.DefaultMediaType) + ); } /// @@ -303,7 +298,11 @@ public static TResult Respond(this IResponds responds, HttpSta throw new ArgumentNullException(nameof(streamContent)); } - return responds.RespondUsing(new FromStreamStrategy(statusCode, streamContent, mediaType)); + return responds.Respond(with => with + .StatusCode(statusCode) + .Body(streamContent) + .ContentType(mediaType ?? ResponseBuilder.DefaultMediaType) + ); } /// @@ -336,10 +335,11 @@ public static TResult Respond(this IResponds responds, HttpSta throw new ArgumentNullException(nameof(content)); } - return responds.Respond(async () => new HttpResponseMessage(statusCode) - { - Content = await content.CloneAsByteArrayContentAsync().ConfigureAwait(false) - }); + return responds.Respond(with => with + .StatusCode(statusCode) + // Caller is responsible for disposal of the original content. + .Body(async _ => await content.CloneAsByteArrayContentAsync().ConfigureAwait(false)) + ); } /// @@ -376,6 +376,29 @@ public static TResult TimesOutAfter(this IResponds responds, T throw new ArgumentNullException(nameof(responds)); } - return responds.RespondUsing(new TimeoutStrategy(timeoutAfter)); + return responds.Respond(with => with.TimesOutAfter(timeoutAfter)); + } + + /// + /// Specifies to throw a after a specified amount of time, simulating a HTTP request timeout. + /// + /// + /// The response builder. + public static TResult Respond(this IResponds responds, Action with) + where TResult : IResponseResult + { + if (responds is null) + { + throw new ArgumentNullException(nameof(responds)); + } + + if (with is null) + { + throw new ArgumentNullException(nameof(with)); + } + + var builder = new ResponseBuilder(); + with(builder); + return responds.RespondUsing(builder); } } diff --git a/src/MockHttp/Extensions/ResponseBuilderExtensions.cs b/src/MockHttp/Extensions/ResponseBuilderExtensions.cs new file mode 100644 index 00000000..e341c3b4 --- /dev/null +++ b/src/MockHttp/Extensions/ResponseBuilderExtensions.cs @@ -0,0 +1,311 @@ +#nullable enable +using System.Net; +using System.Net.Http.Headers; +using System.Text; +using MockHttp.Http; +using MockHttp.Responses; + +namespace MockHttp; + +/// +/// Response builder extensions. +/// +public static class ResponseBuilderExtensions +{ + /// + /// Sets the status code for the response. + /// + /// The builder. + /// The status code to return with the response. + /// The builder to continue chaining additional behaviors. + /// Thrown when is . + /// Thrown when is less than 100. + public static IResponseBuilder StatusCode(this IResponseBuilder builder, int statusCode) + { + return builder.StatusCode((HttpStatusCode)statusCode); + } + + /// + /// Sets the status code for the response. + /// + /// The builder. + /// The status code to return with the response. + /// The builder to continue chaining additional behaviors. + /// Thrown when is . + /// Thrown when is less than 100. + public static IResponseBuilder StatusCode(this IResponseBuilder builder, HttpStatusCode statusCode) + { + if (builder is null) + { + throw new ArgumentNullException(nameof(builder)); + } + + builder.Behaviors.Add(new StatusCodeBehavior(statusCode)); + return builder; + } + + /// + /// Sets the plain text content for the response. + /// + /// The builder. + /// The plain text content. + /// The optional encoding to use when encoding the text. + /// The builder to continue chaining additional behaviors. + /// Thrown when or is . + public static IResponseBuilder Body(this IResponseBuilder builder, string content, Encoding? encoding = null) + { + if (content is null) + { + throw new ArgumentNullException(nameof(content)); + } + + return builder.Body(_ => Task.FromResult(new StringContent(content, encoding))); + } + + /// + /// Sets the binary content for the response. + /// + /// The builder. + /// The binary content. + /// The builder to continue chaining additional behaviors. + /// Thrown when or is . + public static IResponseBuilder Body(this IResponseBuilder builder, byte[] content) + { + return builder.Body(content, 0, content.Length); + } + + /// + /// Sets the binary content for the response. + /// + /// The builder. + /// The binary content. + /// The offset in the array. + /// The number of bytes to return, starting from the offset. + /// The builder to continue chaining additional behaviors. + /// Thrown when or is . + /// Thrown when or exceeds beyond end of array. + public static IResponseBuilder Body(this IResponseBuilder builder, byte[] content, int offset, int count) + { + if (offset < 0 || offset > content.Length) + { + throw new ArgumentOutOfRangeException(nameof(offset)); + } + + if (count < 0 || count > content.Length - offset) + { + throw new ArgumentOutOfRangeException(nameof(count)); + } + + return builder.Body(_ => Task.FromResult(new ByteArrayContent(content, offset, count))); + } + + /// + /// Sets the binary content for the response. + /// It is recommend to use the overload that accepts Func<Stream> with very large streams due to internal in-memory buffering. + /// + /// The builder. + /// The binary content. + /// The builder to continue chaining additional behaviors. + /// Thrown when or is . + /// Thrown when the stream does not support reading. + public static IResponseBuilder Body(this IResponseBuilder builder, Stream content) + { + if (content is null) + { + throw new ArgumentNullException(nameof(content)); + } + + if (!content.CanRead) + { + throw new ArgumentException("Cannot read from stream.", nameof(content)); + } + + // We must copy byte buffer because streams are not thread safe nor are reset to offset 0 after first use. + byte[] buffer; + using (var ms = new MemoryStream()) + { + content.CopyTo(ms); + // This could be an issue with very big streams. + // Should we throw if greater than a certain size and force use of Func instead? + buffer = ms.ToArray(); + } + + return builder.Body(buffer); + } + + /// + /// Sets the binary content for the response using a factory returning a new on each invocation. + /// + /// The builder. + /// The factory returning a new on each invocation containing the binary content. + /// The builder to continue chaining additional behaviors. + /// Thrown when or is . + public static IResponseBuilder Body(this IResponseBuilder builder, Func streamFactory) + { + if (streamFactory is null) + { + throw new ArgumentNullException(nameof(streamFactory)); + } + + return builder.Body(_ => + { + Stream stream = streamFactory(); + if (!stream.CanRead) + { + throw new ArgumentException("Cannot read from stream.", nameof(stream)); + } + + return Task.FromResult(new StreamContent(stream)); + }); + } + + /// + /// Sets the content for the response using a factory returning a new on each invocation. + /// + /// The builder. + /// The factory returning a new instance of on each invocation. + /// The builder to continue chaining additional behaviors. + /// Thrown when or is . + public static IResponseBuilder Body(this IResponseBuilder builder, Func> httpContentFactory) + { + if (builder is null) + { + throw new ArgumentNullException(nameof(builder)); + } + + if (httpContentFactory is null) + { + throw new ArgumentNullException(nameof(httpContentFactory)); + } + + builder.Behaviors.Add(new HttpContentBehavior(httpContentFactory)); + return builder; + } + + /// + /// Sets the content type for the response. Will be ignored if no content is set. + /// + /// The builder. + /// The content type. + /// The optional encoding. + /// The builder to continue chaining additional behaviors. + /// Thrown when or is . + public static IResponseBuilder ContentType(this IResponseBuilder builder, string contentType, Encoding? encoding = null) + { + if (contentType is null) + { + throw new ArgumentNullException(nameof(contentType)); + } + + var mediaType = MediaTypeHeaderValue.Parse(contentType); + if (encoding is not null) + { + mediaType.CharSet = encoding.WebName; + } + + return builder.ContentType(mediaType); + } + + /// + /// Sets the content type for the response. Will be ignored if no content is set. + /// + /// The builder. + /// The media type. + /// The builder to continue chaining additional behaviors. + /// Thrown when or is . + public static IResponseBuilder ContentType(this IResponseBuilder builder, MediaTypeHeaderValue mediaType) + { + if (mediaType is null) + { + throw new ArgumentNullException(nameof(mediaType)); + } + + return builder.Header("Content-Type", mediaType.ToString()); + } + + /// + /// Adds a HTTP header value. + /// + /// The builder. + /// The header name. + /// The header value. + /// The builder to continue chaining additional behaviors. + /// Thrown when or is . + public static IResponseBuilder Header(this IResponseBuilder builder, string name, string value) + { + return builder.Header(name, new[] { value }); + } + + /// + /// Adds HTTP header with values. + /// + /// The builder. + /// The header name. + /// The header values. + /// The builder to continue chaining additional behaviors. + /// Thrown when or is . + public static IResponseBuilder Header(this IResponseBuilder builder, string name, IEnumerable values) + { + if (name is null) + { + throw new ArgumentNullException(nameof(name)); + } + + return builder.Headers(new HttpHeadersCollection { { name, values } }); + } + + /// + /// Adds HTTP headers. + /// + /// The builder. + /// The headers. + /// The builder to continue chaining additional behaviors. + /// Thrown when or is . + public static IResponseBuilder Headers(this IResponseBuilder builder, IEnumerable>> headers) + { + if (builder is null) + { + throw new ArgumentNullException(nameof(builder)); + } + + if (headers is null) + { + throw new ArgumentNullException(nameof(headers)); + } + + builder.Behaviors.Add(new HttpHeaderBehavior(headers)); + return builder; + } + + /// + /// Specifies to throw a simulating a HTTP request timeout. + /// Note: the response is short-circuited when running outside of the HTTP server. To simulate server timeouts, use .StatusCode(HttpStatusCode.RequestTimeout) instead. + /// + /// The builder. + /// The builder to continue chaining additional behaviors. + /// Thrown when is . + public static IResponseBuilder TimesOut(this IResponseBuilder builder) + { + return builder.TimesOutAfter(TimeSpan.Zero); + } + + /// + /// Specifies to throw a after a specified amount of time, simulating a HTTP request timeout. + /// Note: the response is short-circuited when running outside of the HTTP server. To simulate server timeouts, use .StatusCode(HttpStatusCode.RequestTimeout) instead. + /// + /// The builder. + /// The time after which the timeout occurs. + /// The builder to continue chaining additional behaviors. + /// Thrown when is . + public static IResponseBuilder TimesOutAfter(this IResponseBuilder builder, TimeSpan timeoutAfter) + { + if (builder is null) + { + throw new ArgumentNullException(nameof(builder)); + } + + builder.Behaviors.Add(new TimeoutBehavior(timeoutAfter)); + return builder; + } +} +#nullable restore diff --git a/src/MockHttp/IResponseBuilder.cs b/src/MockHttp/IResponseBuilder.cs new file mode 100644 index 00000000..984ec5cd --- /dev/null +++ b/src/MockHttp/IResponseBuilder.cs @@ -0,0 +1,16 @@ +using System.ComponentModel; +using MockHttp.Responses; + +namespace MockHttp; + +/// +/// A builder to compose HTTP responses via a behavior pipeline. +/// +[EditorBrowsable(EditorBrowsableState.Never)] +public interface IResponseBuilder : IFluentInterface +{ + /// + /// Gets a list of behaviors to modify the HTTP response returned. + /// + IList Behaviors { get; } +} diff --git a/src/MockHttp/ResponseBuilder.cs b/src/MockHttp/ResponseBuilder.cs new file mode 100644 index 00000000..44759984 --- /dev/null +++ b/src/MockHttp/ResponseBuilder.cs @@ -0,0 +1,44 @@ +#nullable enable +using System.Net.Http.Headers; +using System.Text; +using MockHttp.Responses; + +namespace MockHttp; + +internal class ResponseBuilder + : IResponseStrategy, + IResponseBuilder +{ + internal static readonly MediaTypeHeaderValue DefaultMediaType = new("text/plain"); + internal static readonly Encoding DefaultWebEncoding = Encoding.UTF8; + + private static readonly Dictionary BehaviorPriority = new() + { + { typeof(TimeoutBehavior), 0 }, + { typeof(StatusCodeBehavior), 1 }, + { typeof(HttpContentBehavior), 2 }, + { typeof(HttpHeaderBehavior), 3 } + }; + + async Task IResponseStrategy.ProduceResponseAsync(MockHttpRequestContext requestContext, CancellationToken cancellationToken) + { + static Task Seed(MockHttpRequestContext requestContext, HttpResponseMessage responseMessage, CancellationToken cancellationToken) => Task.CompletedTask; + + var response = new HttpResponseMessage(); + + await Behaviors + .ToArray() // clone to array prevent the list from changing while executing. + .OrderBy(behavior => BehaviorPriority.TryGetValue(behavior.GetType(), out int priority) ? priority : int.MaxValue) + .Reverse() + .Aggregate((ResponseHandlerDelegate)Seed, + (next, pipeline) => (context, message, ct) => pipeline.HandleAsync(context, message, next, ct) + )(requestContext, response, cancellationToken); + + return response; + } + + /// + public IList Behaviors { get; } = new List(); +} + +#nullable restore diff --git a/src/MockHttp/Responses/FromStreamStrategy.cs b/src/MockHttp/Responses/FromStreamStrategy.cs deleted file mode 100644 index 6cdb466a..00000000 --- a/src/MockHttp/Responses/FromStreamStrategy.cs +++ /dev/null @@ -1,38 +0,0 @@ -using System.Net; -using System.Net.Http.Headers; - -namespace MockHttp.Responses; - -/// -/// Strategy that buffers a stream to a byte array, and serves responses with the byte array as content. -/// -internal sealed class FromStreamStrategy : IResponseStrategy -{ - private readonly Func _content; - private readonly HttpStatusCode _statusCode; - private readonly MediaTypeHeaderValue _mediaType; - - public FromStreamStrategy(HttpStatusCode statusCode, Func content, MediaTypeHeaderValue mediaType) - { - _content = content ?? throw new ArgumentNullException(nameof(content)); - _statusCode = statusCode; - _mediaType = mediaType; - } - - public Task ProduceResponseAsync(MockHttpRequestContext requestContext, CancellationToken cancellationToken) - { - Stream stream = _content(); - if (!stream.CanRead) - { - throw new IOException("Cannot read from stream."); - } - - return Task.FromResult(new HttpResponseMessage(_statusCode) - { - Content = new StreamContent(stream) - { - Headers = { ContentType = _mediaType } - } - }); - } -} diff --git a/src/MockHttp/Responses/HttpContentBehavior.cs b/src/MockHttp/Responses/HttpContentBehavior.cs new file mode 100644 index 00000000..b0ba43b3 --- /dev/null +++ b/src/MockHttp/Responses/HttpContentBehavior.cs @@ -0,0 +1,20 @@ +#nullable enable +namespace MockHttp.Responses; + +internal sealed class HttpContentBehavior + : IResponseBehavior +{ + private readonly Func> _httpContentFactory; + + public HttpContentBehavior(Func> httpContentFactory) + { + _httpContentFactory = httpContentFactory ?? throw new ArgumentNullException(nameof(httpContentFactory)); + } + + public async Task HandleAsync(MockHttpRequestContext requestContext, HttpResponseMessage responseMessage, ResponseHandlerDelegate next, CancellationToken cancellationToken) + { + responseMessage.Content = await _httpContentFactory(requestContext); + await next(requestContext, responseMessage, cancellationToken).ConfigureAwait(false); + } +} +#nullable restore diff --git a/src/MockHttp/Responses/HttpHeaderBehavior.cs b/src/MockHttp/Responses/HttpHeaderBehavior.cs new file mode 100644 index 00000000..7f1cf5fd --- /dev/null +++ b/src/MockHttp/Responses/HttpHeaderBehavior.cs @@ -0,0 +1,54 @@ +#nullable enable +namespace MockHttp.Responses; + +internal sealed class HttpHeaderBehavior + : IResponseBehavior +{ + //https://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html + private static readonly ISet HeadersWithSingleValueOnly = new HashSet + { + // TODO: expand this list. + "Age", + "Authorization", + "Connection", + "Content-Length", + "Content-Location", + "Content-MD5", + "Content-Range", + "Content-Type", + "Date", + "ETag", + "Expires", + "Host", + "Last-Modified", + "Location", + "Max-Forwards", + "Retry-After", + "Server" + }; + + private readonly IList>> _headers; + + public HttpHeaderBehavior(IEnumerable>> headers) + { + _headers = headers?.ToList() ?? throw new ArgumentNullException(nameof(headers)); + } + + public Task HandleAsync(MockHttpRequestContext requestContext, HttpResponseMessage responseMessage, ResponseHandlerDelegate next, CancellationToken cancellationToken) + { + // ReSharper disable once UseDeconstruction + foreach (KeyValuePair> header in _headers) + { + // Special case handling of headers which only allow single values. + if (HeadersWithSingleValueOnly.Contains(header.Key)) + { + responseMessage.Content?.Headers.Remove(header.Key); + } + + responseMessage.Content?.Headers.Add(header.Key, header.Value); + } + + return next(requestContext, responseMessage, cancellationToken); + } +} +#nullable restore diff --git a/src/MockHttp/Responses/IResponseBehavior.cs b/src/MockHttp/Responses/IResponseBehavior.cs new file mode 100644 index 00000000..10c53463 --- /dev/null +++ b/src/MockHttp/Responses/IResponseBehavior.cs @@ -0,0 +1,25 @@ +#nullable enable +namespace MockHttp.Responses; + +/// +/// A delegate which when executed returns a configured HTTP response. +/// +/// +public delegate Task ResponseHandlerDelegate(MockHttpRequestContext requestContext, HttpResponseMessage responseMessage, CancellationToken cancellationToken); + +/// +/// Describes a way to apply a response behavior in a response builder pipeline. +/// +public interface IResponseBehavior +{ + /// + /// Executes the behavior. Call to execute the next behavior in the response pipeline and use its returned response message. + /// + /// The current request context. + /// The response message. + /// The next behavior. + /// The cancellation token. + /// An awaitable that upon completion returns the HTTP response message. + Task HandleAsync(MockHttpRequestContext requestContext, HttpResponseMessage responseMessage, ResponseHandlerDelegate next, CancellationToken cancellationToken); +} +#nullable restore diff --git a/src/MockHttp/Responses/StatusCodeBehavior.cs b/src/MockHttp/Responses/StatusCodeBehavior.cs new file mode 100644 index 00000000..2aa9c304 --- /dev/null +++ b/src/MockHttp/Responses/StatusCodeBehavior.cs @@ -0,0 +1,27 @@ +#nullable enable +using System.Net; + +namespace MockHttp.Responses; + +internal sealed class StatusCodeBehavior + : IResponseBehavior +{ + private readonly HttpStatusCode _statusCode; + + public StatusCodeBehavior(HttpStatusCode statusCode) + { + if ((int)statusCode < 100) + { + throw new ArgumentOutOfRangeException(nameof(statusCode)); + } + + _statusCode = statusCode; + } + + public Task HandleAsync(MockHttpRequestContext requestContext, HttpResponseMessage responseMessage, ResponseHandlerDelegate next, CancellationToken cancellationToken) + { + responseMessage.StatusCode = _statusCode; + return next(requestContext, responseMessage, cancellationToken); + } +} +#nullable restore diff --git a/src/MockHttp/Responses/TimeoutStrategy.cs b/src/MockHttp/Responses/TimeoutBehavior.cs similarity index 64% rename from src/MockHttp/Responses/TimeoutStrategy.cs rename to src/MockHttp/Responses/TimeoutBehavior.cs index dd6863c4..81119671 100644 --- a/src/MockHttp/Responses/TimeoutStrategy.cs +++ b/src/MockHttp/Responses/TimeoutBehavior.cs @@ -1,12 +1,14 @@ -namespace MockHttp.Responses; +#nullable enable +namespace MockHttp.Responses; -internal sealed class TimeoutStrategy : IResponseStrategy +internal sealed class TimeoutBehavior + : IResponseBehavior { private readonly TimeSpan _timeoutAfter; - public TimeoutStrategy(TimeSpan timeoutAfter) + public TimeoutBehavior(TimeSpan timeoutAfter) { - if (timeoutAfter.TotalMilliseconds <= -1L || timeoutAfter.TotalMilliseconds > int.MaxValue) + if (timeoutAfter.TotalMilliseconds is <= -1L or > int.MaxValue) { throw new ArgumentOutOfRangeException(nameof(timeoutAfter)); } @@ -14,7 +16,7 @@ public TimeoutStrategy(TimeSpan timeoutAfter) _timeoutAfter = timeoutAfter; } - public Task ProduceResponseAsync(MockHttpRequestContext requestContext, CancellationToken cancellationToken) + public Task HandleAsync(MockHttpRequestContext requestContext, HttpResponseMessage responseMessage, ResponseHandlerDelegate next, CancellationToken cancellationToken) { // It is somewhat unintuitive to throw TaskCanceledException but this is what HttpClient does atm, // so we simulate same behavior. @@ -30,3 +32,4 @@ public Task ProduceResponseAsync(MockHttpRequestContext req .Unwrap(); } } +#nullable restore diff --git a/test/MockHttp.Tests/Responses/TimeoutStrategyTests.cs b/test/MockHttp.Tests/Responses/TimeoutBehaviorTests.cs similarity index 69% rename from test/MockHttp.Tests/Responses/TimeoutStrategyTests.cs rename to test/MockHttp.Tests/Responses/TimeoutBehaviorTests.cs index 74bc4ed2..7f6076fe 100644 --- a/test/MockHttp.Tests/Responses/TimeoutStrategyTests.cs +++ b/test/MockHttp.Tests/Responses/TimeoutBehaviorTests.cs @@ -1,10 +1,11 @@ using System.Diagnostics; using FluentAssertions; +using Moq; using Xunit; namespace MockHttp.Responses; -public class TimeoutStrategyTests +public class TimeoutBehaviorTests { [Theory] [InlineData(10)] @@ -14,11 +15,12 @@ public class TimeoutStrategyTests public async Task Given_timeout_when_sending_should_timeout_after_time_passed(int timeoutInMilliseconds) { var timeout = TimeSpan.FromMilliseconds(timeoutInMilliseconds); - var sut = new TimeoutStrategy(timeout); + var sut = new TimeoutBehavior(timeout); var sw = new Stopwatch(); + var next = new Mock(); // Act - Func act = () => sut.ProduceResponseAsync(new MockHttpRequestContext(new HttpRequestMessage()), CancellationToken.None); + Func act = () => sut.HandleAsync(new MockHttpRequestContext(new HttpRequestMessage()), new HttpResponseMessage(), next.Object, CancellationToken.None); // Assert sw.Start(); @@ -26,22 +28,25 @@ public async Task Given_timeout_when_sending_should_timeout_after_time_passed(in sw.Elapsed.Should() // Allow 5% diff. .BeGreaterThan(timeout - TimeSpan.FromMilliseconds(timeoutInMilliseconds * 0.95)); + next.VerifyNoOtherCalls(); } [Fact] public async Task Given_cancellation_token_is_cancelled_when_sending_should_throw() { var timeout = TimeSpan.FromSeconds(60); - var sut = new TimeoutStrategy(timeout); + var sut = new TimeoutBehavior(timeout); var ct = new CancellationToken(true); + var next = new Mock(); // Act var sw = Stopwatch.StartNew(); - Func act = () => sut.ProduceResponseAsync(new MockHttpRequestContext(new HttpRequestMessage()), ct); + Func act = () => sut.HandleAsync(new MockHttpRequestContext(new HttpRequestMessage()), new HttpResponseMessage(), next.Object, ct); // Assert await act.Should().ThrowAsync(); sw.Elapsed.Should().BeLessThan(timeout); + next.VerifyNoOtherCalls(); } [Theory] @@ -54,7 +59,7 @@ public void Given_invalid_timespan_when_sending_should_throw(double milliseconds var invalidTimeout = TimeSpan.FromMilliseconds(milliseconds); // Act - Func act = () => new TimeoutStrategy(invalidTimeout); + Func act = () => new TimeoutBehavior(invalidTimeout); // Assert act.Should() From 54ae9d35afa80e7431b3c5208eb6c2df0686f66c Mon Sep 17 00:00:00 2001 From: skwasjer Date: Wed, 4 May 2022 17:52:15 +0200 Subject: [PATCH 2/6] feat: add JSON fluent response builder API support --- src/MockHttp.Json/Constants.cs | 2 +- src/MockHttp.Json/DeprecationWarnings.cs | 6 ++ .../MockHttpRequestContextExtensions.cs | 1 + .../Extensions/ResponseBuilderExtensions.cs | 59 +++++++++++++++++++ src/MockHttp.Json/JsonResponseStrategy.cs | 55 ----------------- .../MockHttp.Json.csproj.DotSettings | 2 + .../Newtonsoft/RespondsExtensions.cs | 5 ++ .../Newtonsoft/ResponseBuilderExtensions.cs | 51 ++++++++++++++++ src/MockHttp.Json/RespondsExtensions.cs | 30 +++++++--- .../ResponseBuilderExtensions.cs | 51 ++++++++++++++++ .../JsonRespondsExtensionsTests.cs | 1 + 11 files changed, 199 insertions(+), 64 deletions(-) create mode 100644 src/MockHttp.Json/DeprecationWarnings.cs create mode 100644 src/MockHttp.Json/Extensions/ResponseBuilderExtensions.cs delete mode 100644 src/MockHttp.Json/JsonResponseStrategy.cs create mode 100644 src/MockHttp.Json/MockHttp.Json.csproj.DotSettings create mode 100644 src/MockHttp.Json/Newtonsoft/ResponseBuilderExtensions.cs create mode 100644 src/MockHttp.Json/SystemTextJson/ResponseBuilderExtensions.cs diff --git a/src/MockHttp.Json/Constants.cs b/src/MockHttp.Json/Constants.cs index 4e5d1adb..935df3bf 100644 --- a/src/MockHttp.Json/Constants.cs +++ b/src/MockHttp.Json/Constants.cs @@ -2,5 +2,5 @@ internal static class MediaTypes { - public const string JsonMediaTypeWithUtf8 = "application/json; charset=utf-8"; + public const string JsonMediaType = "application/json"; } diff --git a/src/MockHttp.Json/DeprecationWarnings.cs b/src/MockHttp.Json/DeprecationWarnings.cs new file mode 100644 index 00000000..0dc7d71c --- /dev/null +++ b/src/MockHttp.Json/DeprecationWarnings.cs @@ -0,0 +1,6 @@ +namespace MockHttp.Json; + +internal static class DeprecationWarnings +{ + public const string RespondsExtensions = "Obsolete, will be removed in next major release. Use the .Respond(with => with.JsonBody(..)) extension methods."; +} diff --git a/src/MockHttp.Json/Extensions/MockHttpRequestContextExtensions.cs b/src/MockHttp.Json/Extensions/MockHttpRequestContextExtensions.cs index 444a2813..c3c511cb 100644 --- a/src/MockHttp.Json/Extensions/MockHttpRequestContextExtensions.cs +++ b/src/MockHttp.Json/Extensions/MockHttpRequestContextExtensions.cs @@ -1,5 +1,6 @@ using MockHttp.Responses; +// ReSharper disable once CheckNamespace : BREAKING - change namespace with next release. (remove Extensions) namespace MockHttp.Json.Extensions; internal static class MockHttpRequestContextExtensions diff --git a/src/MockHttp.Json/Extensions/ResponseBuilderExtensions.cs b/src/MockHttp.Json/Extensions/ResponseBuilderExtensions.cs new file mode 100644 index 00000000..4402c4c4 --- /dev/null +++ b/src/MockHttp.Json/Extensions/ResponseBuilderExtensions.cs @@ -0,0 +1,59 @@ +using System.Net.Http.Headers; +using System.Text; +using MockHttp.Json.Extensions; +using MockHttp.Responses; + +namespace MockHttp.Json; + +/// +/// Response builder extensions. +/// +public static class ResponseBuilderExtensions +{ + /// + /// Sets the JSON content for the response. + /// + /// The builder. + /// The object to be returned as JSON. + /// The optional JSON encoding. + /// The optional JSON adapter. When null uses the default adapter. + /// The builder to continue chaining additional behaviors. + /// Thrown when is . + public static IResponseBuilder JsonBody(this IResponseBuilder builder, T jsonContent, Encoding? encoding = null, IJsonAdapter? adapter = null) + { + return builder.JsonBody(_ => jsonContent, encoding, adapter); + } + + /// + /// Sets the JSON content for the response using a factory returning a new instance of on each invocation. + /// + /// The builder. + /// The factory which creates an object to be returned as JSON. + /// The optional JSON encoding. + /// The optional JSON adapter. When null uses the default adapter. + /// The builder to continue chaining additional behaviors. + /// Thrown when or is . + public static IResponseBuilder JsonBody(this IResponseBuilder builder, Func jsonContentFactory, Encoding? encoding = null, IJsonAdapter? adapter = null) + { + if (jsonContentFactory is null) + { + throw new ArgumentNullException(nameof(jsonContentFactory)); + } + + return builder.Body(requestContext => + { + IJsonAdapter jsonSerializerAdapter = adapter ?? requestContext.GetAdapter(); + object? value = jsonContentFactory(requestContext); + + var httpContent = new StringContent(jsonSerializerAdapter.Serialize(value), encoding) + { + Headers = + { + ContentType = new MediaTypeHeaderValue(MediaTypes.JsonMediaType) { CharSet = (encoding ?? Encoding.UTF8).WebName } + } + }; + + return Task.FromResult(httpContent); + }); + } +} diff --git a/src/MockHttp.Json/JsonResponseStrategy.cs b/src/MockHttp.Json/JsonResponseStrategy.cs deleted file mode 100644 index 1bda27ee..00000000 --- a/src/MockHttp.Json/JsonResponseStrategy.cs +++ /dev/null @@ -1,55 +0,0 @@ -using System.Net; -using System.Net.Http.Headers; -using MockHttp.Json.Extensions; -using MockHttp.Responses; - -namespace MockHttp.Json; - -internal class JsonResponseStrategy : ObjectResponseStrategy -{ - private readonly IJsonAdapter? _adapter; - - public JsonResponseStrategy - ( - HttpStatusCode statusCode, - Type typeOfValue, - Func valueFactory, - MediaTypeHeaderValue mediaType, - IJsonAdapter? adapter) - : base(statusCode, typeOfValue, valueFactory, mediaType) - { - _adapter = adapter; - } - - public override Task ProduceResponseAsync(MockHttpRequestContext requestContext, CancellationToken cancellationToken) - { - IJsonAdapter jsonSerializerAdapter = _adapter ?? requestContext.GetAdapter(); - object? value = ValueFactory(requestContext.Request); - return Task.FromResult(new HttpResponseMessage(StatusCode) - { - Content = new StringContent(jsonSerializerAdapter.Serialize(value)) - { - Headers = - { - ContentType = MediaType ?? MediaTypeHeaderValue.Parse(MediaTypes.JsonMediaTypeWithUtf8) - } - } - }); - } -} - -internal class JsonResponseStrategy : JsonResponseStrategy -{ - public JsonResponseStrategy(HttpStatusCode statusCode, Func valueFactory, MediaTypeHeaderValue mediaType, IJsonAdapter? adapter) - : base( - statusCode, - typeof(T), - r => valueFactory is not null - ? valueFactory(r) - : throw new ArgumentNullException(nameof(valueFactory)), - mediaType, - adapter - ) - { - } -} diff --git a/src/MockHttp.Json/MockHttp.Json.csproj.DotSettings b/src/MockHttp.Json/MockHttp.Json.csproj.DotSettings new file mode 100644 index 00000000..17962b13 --- /dev/null +++ b/src/MockHttp.Json/MockHttp.Json.csproj.DotSettings @@ -0,0 +1,2 @@ + + True \ No newline at end of file diff --git a/src/MockHttp.Json/Newtonsoft/RespondsExtensions.cs b/src/MockHttp.Json/Newtonsoft/RespondsExtensions.cs index e8d8d367..e7bb175b 100644 --- a/src/MockHttp.Json/Newtonsoft/RespondsExtensions.cs +++ b/src/MockHttp.Json/Newtonsoft/RespondsExtensions.cs @@ -9,6 +9,7 @@ namespace MockHttp.Json.Newtonsoft; /// /// JSON extensions for . /// +[Obsolete(DeprecationWarnings.RespondsExtensions, false)] public static class RespondsExtensions { /// @@ -18,6 +19,7 @@ public static class RespondsExtensions /// The response content. /// The media type. Can be null, in which case the default JSON content type will be used. /// The serializer settings. + [Obsolete(DeprecationWarnings.RespondsExtensions, false)] public static TResult RespondJson ( this IResponds responds, @@ -36,6 +38,7 @@ public static TResult RespondJson /// The response content. /// The media type. Can be null, in which case the default JSON content type will be used. /// The serializer settings. + [Obsolete(DeprecationWarnings.RespondsExtensions, false)] public static TResult RespondJson ( this IResponds responds, @@ -55,6 +58,7 @@ public static TResult RespondJson /// The response content. /// The media type. Can be null, in which case the default JSON content type will be used. /// The serializer settings. + [Obsolete(DeprecationWarnings.RespondsExtensions, false)] public static TResult RespondJson ( this IResponds responds, @@ -75,6 +79,7 @@ public static TResult RespondJson /// The response content. /// The media type. Can be null, in which case the default JSON content type will be used. /// The serializer settings. + [Obsolete(DeprecationWarnings.RespondsExtensions, false)] public static TResult RespondJson ( this IResponds responds, diff --git a/src/MockHttp.Json/Newtonsoft/ResponseBuilderExtensions.cs b/src/MockHttp.Json/Newtonsoft/ResponseBuilderExtensions.cs new file mode 100644 index 00000000..3c8b0d81 --- /dev/null +++ b/src/MockHttp.Json/Newtonsoft/ResponseBuilderExtensions.cs @@ -0,0 +1,51 @@ +using System.Text; +using MockHttp.Responses; +using Newtonsoft.Json; + +namespace MockHttp.Json.Newtonsoft; + +/// +/// Response builder extensions. +/// +public static class ResponseBuilderExtensions +{ + /// + /// Sets the JSON content for the response. + /// + /// The builder. + /// The object to be returned as JSON. + /// The optional JSON encoding. + /// The optional JSON serializer settings. When null uses the default serializer settings. + /// The builder to continue chaining additional behaviors. + /// Thrown when is . + public static IResponseBuilder JsonBody + ( + this IResponseBuilder builder, + T jsonContent, + Encoding? encoding = null, + JsonSerializerSettings? serializerSettings = null + ) + { + return builder.JsonBody(jsonContent, encoding, new NewtonsoftAdapter(serializerSettings)); + } + + /// + /// Sets the JSON content for the response using a factory returning a new instance of on each invocation. + /// + /// The builder. + /// The factory which creates an object to be returned as JSON. + /// The optional JSON encoding. + /// The optional JSON serializer settings. When null uses the default serializer settings. + /// The builder to continue chaining additional behaviors. + /// Thrown when or is . + public static IResponseBuilder JsonBody + ( + this IResponseBuilder builder, + Func jsonContentFactory, + Encoding? encoding = null, + JsonSerializerSettings? serializerSettings = null + ) + { + return builder.JsonBody(jsonContentFactory, encoding, new NewtonsoftAdapter(serializerSettings)); + } +} diff --git a/src/MockHttp.Json/RespondsExtensions.cs b/src/MockHttp.Json/RespondsExtensions.cs index 9849c5ad..6d1af5a2 100644 --- a/src/MockHttp.Json/RespondsExtensions.cs +++ b/src/MockHttp.Json/RespondsExtensions.cs @@ -1,5 +1,6 @@ using System.Net; using System.Net.Http.Headers; +using System.Text; using MockHttp.Language; using MockHttp.Language.Flow; @@ -8,6 +9,7 @@ namespace MockHttp.Json; /// /// JSON extensions for . /// +[Obsolete(DeprecationWarnings.RespondsExtensions, false)] public static class RespondsExtensions { /// @@ -15,6 +17,7 @@ public static class RespondsExtensions /// /// /// The response content. + [Obsolete(DeprecationWarnings.RespondsExtensions, false)] public static TResult RespondJson(this IResponds responds, T content) where TResult : IResponseResult { @@ -26,6 +29,7 @@ public static TResult RespondJson(this IResponds responds, /// /// /// The response content. + [Obsolete(DeprecationWarnings.RespondsExtensions, false)] public static TResult RespondJson(this IResponds responds, Func content) where TResult : IResponseResult { @@ -38,6 +42,7 @@ public static TResult RespondJson(this IResponds responds, /// /// The status code response for given request. /// The response content. + [Obsolete(DeprecationWarnings.RespondsExtensions, false)] public static TResult RespondJson(this IResponds responds, HttpStatusCode statusCode, T content) where TResult : IResponseResult { @@ -50,6 +55,7 @@ public static TResult RespondJson(this IResponds responds, /// /// The status code response for given request. /// The response content. + [Obsolete(DeprecationWarnings.RespondsExtensions, false)] public static TResult RespondJson(this IResponds responds, HttpStatusCode statusCode, Func content) where TResult : IResponseResult { @@ -62,6 +68,7 @@ public static TResult RespondJson(this IResponds responds, /// /// The response content. /// The media type. Can be null, in which case the default JSON content type will be used. + [Obsolete(DeprecationWarnings.RespondsExtensions, false)] public static TResult RespondJson(this IResponds responds, T content, MediaTypeHeaderValue? mediaType) where TResult : IResponseResult { @@ -74,6 +81,7 @@ public static TResult RespondJson(this IResponds responds, /// /// The response content. /// The media type. Can be null, in which case the default JSON content type will be used. + [Obsolete(DeprecationWarnings.RespondsExtensions, false)] public static TResult RespondJson(this IResponds responds, Func content, MediaTypeHeaderValue? mediaType) where TResult : IResponseResult { @@ -87,6 +95,7 @@ public static TResult RespondJson(this IResponds responds, /// The status code response for given request. /// The response content. /// The media type. Can be null, in which case the default JSON content type will be used. + [Obsolete(DeprecationWarnings.RespondsExtensions, false)] public static TResult RespondJson(this IResponds responds, HttpStatusCode statusCode, T content, MediaTypeHeaderValue? mediaType) where TResult : IResponseResult { @@ -100,6 +109,7 @@ public static TResult RespondJson(this IResponds responds, /// The status code response for given request. /// The response content. /// The media type. Can be null, in which case the default JSON content type will be used. + [Obsolete(DeprecationWarnings.RespondsExtensions, false)] public static TResult RespondJson(this IResponds responds, HttpStatusCode statusCode, Func content, MediaTypeHeaderValue? mediaType) where TResult : IResponseResult { @@ -114,6 +124,7 @@ public static TResult RespondJson(this IResponds responds, /// The response content. /// The media type. Can be null, in which case the default JSON content type will be used. /// The JSON adapter. + [Obsolete(DeprecationWarnings.RespondsExtensions, false)] public static TResult RespondJson(this IResponds responds, HttpStatusCode statusCode, Func content, MediaTypeHeaderValue? mediaType, IJsonAdapter? adapter) where TResult : IResponseResult { @@ -123,14 +134,13 @@ public static TResult RespondJson(this IResponds responds, } MediaTypeHeaderValue mt = mediaType ?? MediaTypeHeaderValue.Parse("application/json; charset=utf-8"); - - return responds.RespondUsing( - new JsonResponseStrategy( - statusCode, - content, - mt, - adapter - ) + Encoding? enc = mt.CharSet is not null + ? Encoding.GetEncoding(mt.CharSet) + : null; + return responds.Respond(with => with + .StatusCode(statusCode) + .JsonBody(ctx => content(ctx.Request), enc, adapter) + .ContentType(mt) ); } @@ -140,6 +150,7 @@ public static TResult RespondJson(this IResponds responds, /// /// The response content. /// The media type. Can be null, in which case the default JSON content type will be used. + [Obsolete(DeprecationWarnings.RespondsExtensions, false)] public static TResult RespondJson(this IResponds responds, T content, string? mediaType) where TResult : IResponseResult { @@ -152,6 +163,7 @@ public static TResult RespondJson(this IResponds responds, /// /// The response content. /// The media type. Can be null, in which case the default JSON content type will be used. + [Obsolete(DeprecationWarnings.RespondsExtensions, false)] public static TResult RespondJson(this IResponds responds, Func content, string? mediaType) where TResult : IResponseResult { @@ -165,6 +177,7 @@ public static TResult RespondJson(this IResponds responds, /// The status code response for given request. /// The response content. /// The media type. Can be null, in which case the default JSON content type will be used. + [Obsolete(DeprecationWarnings.RespondsExtensions, false)] public static TResult RespondJson(this IResponds responds, HttpStatusCode statusCode, T content, string? mediaType) where TResult : IResponseResult { @@ -178,6 +191,7 @@ public static TResult RespondJson(this IResponds responds, /// The status code response for given request. /// The response content. /// The media type. Can be null, in which case the default JSON content type will be used. + [Obsolete(DeprecationWarnings.RespondsExtensions, false)] public static TResult RespondJson(this IResponds responds, HttpStatusCode statusCode, Func content, string? mediaType) where TResult : IResponseResult { diff --git a/src/MockHttp.Json/SystemTextJson/ResponseBuilderExtensions.cs b/src/MockHttp.Json/SystemTextJson/ResponseBuilderExtensions.cs new file mode 100644 index 00000000..54da5a40 --- /dev/null +++ b/src/MockHttp.Json/SystemTextJson/ResponseBuilderExtensions.cs @@ -0,0 +1,51 @@ +using System.Text; +using System.Text.Json; +using MockHttp.Responses; + +namespace MockHttp.Json.SystemTextJson; + +/// +/// Response builder extensions. +/// +public static class ResponseBuilderExtensions +{ + /// + /// Sets the JSON content for the response. + /// + /// The builder. + /// The object to be returned as JSON. + /// The optional JSON encoding. + /// The optional JSON serializer options. When null uses the default serializer settings. + /// The builder to continue chaining additional behaviors. + /// Thrown when is . + public static IResponseBuilder JsonBody + ( + this IResponseBuilder builder, + T jsonContent, + Encoding? encoding = null, + JsonSerializerOptions? serializerOptions = null + ) + { + return builder.JsonBody(jsonContent, encoding, new SystemTextJsonAdapter(serializerOptions)); + } + + /// + /// Sets the JSON content for the response using a factory returning a new instance of on each invocation. + /// + /// The builder. + /// The factory which creates an object to be returned as JSON. + /// The optional JSON encoding. + /// The optional JSON serializer options. When null uses the default serializer settings. + /// The builder to continue chaining additional behaviors. + /// Thrown when or is . + public static IResponseBuilder JsonBody + ( + this IResponseBuilder builder, + Func jsonContentFactory, + Encoding? encoding = null, + JsonSerializerOptions? serializerOptions = null + ) + { + return builder.JsonBody(jsonContentFactory, encoding, new SystemTextJsonAdapter(serializerOptions)); + } +} diff --git a/test/MockHttp.Json.Tests/JsonRespondsExtensionsTests.cs b/test/MockHttp.Json.Tests/JsonRespondsExtensionsTests.cs index 4b790a31..210fdd72 100644 --- a/test/MockHttp.Json.Tests/JsonRespondsExtensionsTests.cs +++ b/test/MockHttp.Json.Tests/JsonRespondsExtensionsTests.cs @@ -12,6 +12,7 @@ namespace MockHttp.Json; +[Obsolete(DeprecationWarnings.RespondsExtensions, false)] public sealed class JsonRespondsExtensionsTests : IDisposable { private readonly IResponds _sut; From 20e183cb914f889f718dc52ad4468441854d1748 Mon Sep 17 00:00:00 2001 From: skwasjer Date: Wed, 4 May 2022 00:45:39 +0200 Subject: [PATCH 3/6] feat: add network latency behavior to simulate network delays --- .../Extensions/ResponseBuilderExtensions.cs | 29 ++++ src/MockHttp/NetworkLatency.cs | 142 ++++++++++++++++++ src/MockHttp/ResponseBuilder.cs | 7 +- .../Responses/NetworkLatencyBehavior.cs | 17 +++ src/MockHttp/Threading/TaskHelpers.cs | 29 +++- test/MockHttp.Tests/MockHttpHandlerTests.cs | 6 +- test/MockHttp.Tests/NetworkLatencyTests.cs | 77 ++++++++++ 7 files changed, 302 insertions(+), 5 deletions(-) create mode 100644 src/MockHttp/NetworkLatency.cs create mode 100644 src/MockHttp/Responses/NetworkLatencyBehavior.cs create mode 100644 test/MockHttp.Tests/NetworkLatencyTests.cs diff --git a/src/MockHttp/Extensions/ResponseBuilderExtensions.cs b/src/MockHttp/Extensions/ResponseBuilderExtensions.cs index e341c3b4..f92eec8a 100644 --- a/src/MockHttp/Extensions/ResponseBuilderExtensions.cs +++ b/src/MockHttp/Extensions/ResponseBuilderExtensions.cs @@ -307,5 +307,34 @@ public static IResponseBuilder TimesOutAfter(this IResponseBuilder builder, Time builder.Behaviors.Add(new TimeoutBehavior(timeoutAfter)); return builder; } + + /// + /// Adds artificial (simulated) network latency to the request/response. + /// + /// The builder. + /// The network latency to simulate. + /// The builder to continue chaining additional behaviors. + /// Thrown when or is . + public static IResponseBuilder Latency(this IResponseBuilder builder, NetworkLatency latency) + { + if (builder is null) + { + throw new ArgumentNullException(nameof(builder)); + } + + return builder.RegisterBehavior(new NetworkLatencyBehavior(latency)); + } + + /// + /// Adds artificial (simulated) network latency to the request/response. + /// + /// The builder. + /// The network latency to simulate. + /// The builder to continue chaining additional behaviors. + /// Thrown when or is . + public static IResponseBuilder Latency(this IResponseBuilder builder, Func latency) + { + return builder.Latency(latency()); + } } #nullable restore diff --git a/src/MockHttp/NetworkLatency.cs b/src/MockHttp/NetworkLatency.cs new file mode 100644 index 00000000..bee96e6f --- /dev/null +++ b/src/MockHttp/NetworkLatency.cs @@ -0,0 +1,142 @@ +using System.Diagnostics; +using System.Runtime.CompilerServices; +using MockHttp.Threading; + +namespace MockHttp; + +/// +/// Defines different types of latencies to simulate a slow network. +/// +public class NetworkLatency +{ + private static readonly Random Random = new(DateTime.UtcNow.Ticks.GetHashCode()); + + private readonly Func _factory; + private readonly string _name; + + static NetworkLatency() + { + // Warmup so that actual simulated latency is more accurate. + Random.Next(); + } + + private NetworkLatency(Func factory, string name) + { + _factory = factory; + _name = name; + } + + /// + /// Configures 2G network latency (300ms to 1200ms). + /// + public static NetworkLatency TwoG() + { + return Between(300, 1200, nameof(TwoG)); + } + + /// + /// Configures 3G network latency (100ms to 600ms). + /// + public static NetworkLatency ThreeG() + { + return Between(100, 600, nameof(ThreeG)); + } + + /// + /// Configures 4G network latency (30ms to 50ms). + /// + public static NetworkLatency FourG() + { + return Between(30, 50, nameof(FourG)); + } + + /// + /// Configures 5G network latency (5ms to 10ms). + /// + public static NetworkLatency FiveG() + { + return Between(5, 10, nameof(FiveG)); + } + + /// + /// Configures a random latency between and . + /// + public static NetworkLatency Between(TimeSpan min, TimeSpan max) + { + return Between(Convert.ToInt32(min.TotalMilliseconds), Convert.ToInt32(max.TotalMilliseconds), $"{nameof(Between)}({min}, {max})"); + } + + /// + /// Configures a random latency between and . + /// + public static NetworkLatency Between(int minMs, int maxMs) + { + return Between(minMs, maxMs, $"{nameof(Between)}({minMs}, {maxMs})"); + } + + private static NetworkLatency Between(int minMs, int maxMs, string name) + { + if (minMs <= 0) + { + throw new ArgumentOutOfRangeException(nameof(minMs)); + } + + if (maxMs <= minMs) + { + throw new ArgumentOutOfRangeException(nameof(maxMs)); + } + + return new NetworkLatency(() => + { + double randomLatency = Random.Next(minMs, maxMs); + return TimeSpan.FromMilliseconds(randomLatency); + }, + name); + } + + /// + /// Simulates a random latency by introducing a delay before executing the task. + /// + /// The task to execute after the delay. + /// The cancellation token. + /// The random latency generated for this simulation. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal async Task SimulateAsync(Func task, CancellationToken cancellationToken = default) + { + TimeSpan latency = _factory(); + await TaskHelpers.HighResDelay(latency, cancellationToken); + await task(); + return latency; + + //return TaskHelpers.HighResDelay(latency, cancellationToken) + // .ContinueWith(t => t.IsCompleted && !t.IsCanceled && !t.IsFaulted + // ? task() + // : Task.CompletedTask, + // cancellationToken) + // .Unwrap() + // .ContinueWith(t => + // { + // var tcs = new TaskCompletionSource(); + // if (t.IsCanceled) + // { + // tcs.SetCanceled(); + // return tcs.Task; + // } + // else if (t.IsFaulted) + // { + // tcs.SetException(t.Exception); + // return tcs.Task; + // } + + // return Task.FromResult(latency); + // }, + // cancellationToken) + // .Unwrap(); + } + + /// + public override string ToString() + { + return $"{GetType().Name}.{_name}"; + } +} diff --git a/src/MockHttp/ResponseBuilder.cs b/src/MockHttp/ResponseBuilder.cs index 44759984..727ace92 100644 --- a/src/MockHttp/ResponseBuilder.cs +++ b/src/MockHttp/ResponseBuilder.cs @@ -15,9 +15,10 @@ internal class ResponseBuilder private static readonly Dictionary BehaviorPriority = new() { { typeof(TimeoutBehavior), 0 }, - { typeof(StatusCodeBehavior), 1 }, - { typeof(HttpContentBehavior), 2 }, - { typeof(HttpHeaderBehavior), 3 } + { typeof(NetworkLatencyBehavior), 1 }, + { typeof(StatusCodeBehavior), 2 }, + { typeof(HttpContentBehavior), 3 }, + { typeof(HttpHeaderBehavior), 4 } }; async Task IResponseStrategy.ProduceResponseAsync(MockHttpRequestContext requestContext, CancellationToken cancellationToken) diff --git a/src/MockHttp/Responses/NetworkLatencyBehavior.cs b/src/MockHttp/Responses/NetworkLatencyBehavior.cs new file mode 100644 index 00000000..e7b666af --- /dev/null +++ b/src/MockHttp/Responses/NetworkLatencyBehavior.cs @@ -0,0 +1,17 @@ +namespace MockHttp.Responses; + +internal class NetworkLatencyBehavior + : IResponseBehavior +{ + private readonly NetworkLatency _networkLatency; + + public NetworkLatencyBehavior(NetworkLatency networkLatency) + { + _networkLatency = networkLatency ?? throw new ArgumentNullException(nameof(networkLatency)); + } + + public Task HandleAsync(MockHttpRequestContext requestContext, HttpResponseMessage responseMessage, ResponseHandlerDelegate next, CancellationToken cancellationToken) + { + return _networkLatency.SimulateAsync(() => next(requestContext, responseMessage, cancellationToken), cancellationToken); + } +} diff --git a/src/MockHttp/Threading/TaskHelpers.cs b/src/MockHttp/Threading/TaskHelpers.cs index e2c5a3e2..661e3d31 100644 --- a/src/MockHttp/Threading/TaskHelpers.cs +++ b/src/MockHttp/Threading/TaskHelpers.cs @@ -1,4 +1,5 @@ -using System.Runtime.ExceptionServices; +using System.Diagnostics; +using System.Runtime.ExceptionServices; namespace MockHttp.Threading; @@ -42,4 +43,30 @@ private static void RunSyncAndWait(Func action, TimeSpan timeout) } } } + + /// + /// A high res delay, which runs SYNCHRONOUSLY and hence is blocking the current thread. + /// The delay is actually implemented using a spin loop to ensure high precision. Should + /// NOT be used in production code, only for creating and verifying mocks/stubs with MockHttp. + /// + /// The delay time. + /// The cancellation token to abort the delay. + /// A task that can be awaited to finish + internal static Task HighResDelay(TimeSpan delay, CancellationToken cancellationToken = default) + { + var sw = Stopwatch.StartNew(); + + // Task.Delay(0) resolution is around 15ms. + // So we use a spin loop instead to have more accurate simulated delays. + while (true) + { + Thread.SpinWait(10); + if (sw.Elapsed > delay) + { + return Task.CompletedTask; + } + + cancellationToken.ThrowIfCancellationRequested(); + } + } } diff --git a/test/MockHttp.Tests/MockHttpHandlerTests.cs b/test/MockHttp.Tests/MockHttpHandlerTests.cs index ce4da830..1b4deb06 100644 --- a/test/MockHttp.Tests/MockHttpHandlerTests.cs +++ b/test/MockHttp.Tests/MockHttpHandlerTests.cs @@ -373,7 +373,11 @@ public async Task Given_a_request_expectation_when_sending_requests_it_should_co .Callback(() => { }) - .Respond(HttpStatusCode.Accepted, JsonConvert.SerializeObject(new { firstName = "John", lastName = "Doe" })) + .Respond(with => with + .StatusCode(HttpStatusCode.Accepted) + .Body(JsonConvert.SerializeObject(new { firstName = "John", lastName = "Doe" })) + .Latency(NetworkLatency.TwoG) + ) .Verifiable(); // Act diff --git a/test/MockHttp.Tests/NetworkLatencyTests.cs b/test/MockHttp.Tests/NetworkLatencyTests.cs new file mode 100644 index 00000000..dc378d21 --- /dev/null +++ b/test/MockHttp.Tests/NetworkLatencyTests.cs @@ -0,0 +1,77 @@ +using System.Diagnostics; +using FluentAssertions; +using Xunit; +using Xunit.Abstractions; +using static MockHttp.NetworkLatency; + +namespace MockHttp; + +public sealed class NetworkLatencyTests +{ + private readonly ITestOutputHelper _testOutputHelper; + + public NetworkLatencyTests(ITestOutputHelper testOutputHelper) + { + _testOutputHelper = testOutputHelper; + } + + [Theory] + [MemberData(nameof(GetNetworkLatencyTestCases))] + public async Task Given_that_latency_is_configured_when_simulating_it_should_delay_for_expected_time + ( + NetworkLatency networkLatency, + int minExpectedDelayInMs, + int maxExpectedDelayInMs + ) + { + bool isDelegateCalled = false; + + // Act + var sw = Stopwatch.StartNew(); + TimeSpan simulatedLatency = await networkLatency.SimulateAsync(() => + { + isDelegateCalled = true; + return Task.CompletedTask; + }); + TimeSpan executionTime = sw.Elapsed; + + // Assert + _testOutputHelper.WriteLine(networkLatency.ToString()); + _testOutputHelper.WriteLine("Random simulated latency : {0}", simulatedLatency); + _testOutputHelper.WriteLine("Total execution time : {0}", executionTime); + + simulatedLatency + .Should() + .BeGreaterThanOrEqualTo(TimeSpan.FromMilliseconds(minExpectedDelayInMs)) + .And.BeLessThanOrEqualTo(TimeSpan.FromMilliseconds(maxExpectedDelayInMs)); + isDelegateCalled.Should().BeTrue(); + } + + public static IEnumerable GetNetworkLatencyTestCases() + { + yield return new object[] { TwoG(), 300, 1200 }; + yield return new object[] { ThreeG(), 100, 600 }; + yield return new object[] { FourG(), 30, 50 }; + yield return new object[] { FiveG(), 5, 10 }; + yield return new object[] { Between(50, 60), 50, 60 }; + yield return new object[] { Between(TimeSpan.FromMilliseconds(2500), TimeSpan.FromMilliseconds(3000)), 2500, 3000 }; + } + + [Theory] + [MemberData(nameof(GetNetworkLatencyPrettyTextTestCases))] + public void Given_that_latency_is_configured_when_formatting_it_should_return_expected(NetworkLatency networkLatency, string expectedPrettyText) + { + networkLatency.ToString().Should().Be(expectedPrettyText); + } + + public static IEnumerable GetNetworkLatencyPrettyTextTestCases() + { + const string prefix = nameof(NetworkLatency) + "."; + 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[] { Between(50, 60), prefix + "Between(50, 60)" }; + yield return new object[] { Between(TimeSpan.FromMilliseconds(150), TimeSpan.FromMilliseconds(160)), prefix + "Between(00:00:00.1500000, 00:00:00.1600000)" }; + } +} From 40bde66441acda4e006efe24f6c5fe646516f5d9 Mon Sep 17 00:00:00 2001 From: skwasjer Date: Sat, 14 May 2022 08:54:57 +0200 Subject: [PATCH 4/6] refactor: add fluent language/flow, eg. StatusCode must be configured before Body. --- .../Extensions/ResponseBuilderExtensions.cs | 6 +- .../{Constants.cs => MediaTypes.cs} | 0 .../Newtonsoft/ResponseBuilderExtensions.cs | 10 +- src/MockHttp.Json/RespondsExtensions.cs | 28 +++-- .../ResponseBuilderExtensions.cs | 10 +- src/MockHttp/DeprecationWarnings.cs | 7 ++ .../Extensions/IRespondsExtensions.cs | 30 ++++- src/MockHttp/Extensions/ListExtensions.cs | 38 ++++++ .../Extensions/ResponseBuilderExtensions.cs | 119 +++--------------- src/MockHttp/IResponseBuilder.cs | 13 +- .../Flow/Response/IWithContentResult.cs | 15 +++ .../Flow/Response/IWithHeadersResult.cs | 14 +++ .../Flow/Response/IWithStatusCodeResult.cs | 15 +++ .../Language/Flow/Response/ResponseBuilder.cs | 89 +++++++++++++ .../Language/Response/IWithContent.cs | 22 ++++ .../Language/Response/IWithContentType.cs | 22 ++++ .../Language/Response/IWithHeaders.cs | 21 ++++ .../Language/Response/IWithResponse.cs | 17 +++ .../Language/Response/IWithStatusCode.cs | 22 ++++ src/MockHttp/MediaTypes.cs | 7 ++ src/MockHttp/ResponseBuilder.cs | 45 ------- src/MockHttp/Responses/EmptyContent.cs | 47 +++++++ .../Extensions/ListExtensionsTests.cs | 62 +++++++++ .../Responses/EmptyContentTests.cs | 96 ++++++++++++++ 24 files changed, 583 insertions(+), 172 deletions(-) rename src/MockHttp.Json/{Constants.cs => MediaTypes.cs} (100%) create mode 100644 src/MockHttp/DeprecationWarnings.cs create mode 100644 src/MockHttp/Extensions/ListExtensions.cs create mode 100644 src/MockHttp/Language/Flow/Response/IWithContentResult.cs create mode 100644 src/MockHttp/Language/Flow/Response/IWithHeadersResult.cs create mode 100644 src/MockHttp/Language/Flow/Response/IWithStatusCodeResult.cs create mode 100644 src/MockHttp/Language/Flow/Response/ResponseBuilder.cs create mode 100644 src/MockHttp/Language/Response/IWithContent.cs create mode 100644 src/MockHttp/Language/Response/IWithContentType.cs create mode 100644 src/MockHttp/Language/Response/IWithHeaders.cs create mode 100644 src/MockHttp/Language/Response/IWithResponse.cs create mode 100644 src/MockHttp/Language/Response/IWithStatusCode.cs create mode 100644 src/MockHttp/MediaTypes.cs delete mode 100644 src/MockHttp/ResponseBuilder.cs create mode 100644 src/MockHttp/Responses/EmptyContent.cs create mode 100644 test/MockHttp.Tests/Extensions/ListExtensionsTests.cs create mode 100644 test/MockHttp.Tests/Responses/EmptyContentTests.cs diff --git a/src/MockHttp.Json/Extensions/ResponseBuilderExtensions.cs b/src/MockHttp.Json/Extensions/ResponseBuilderExtensions.cs index 4402c4c4..09858fa3 100644 --- a/src/MockHttp.Json/Extensions/ResponseBuilderExtensions.cs +++ b/src/MockHttp.Json/Extensions/ResponseBuilderExtensions.cs @@ -1,6 +1,8 @@ using System.Net.Http.Headers; using System.Text; using MockHttp.Json.Extensions; +using MockHttp.Language.Flow.Response; +using MockHttp.Language.Response; using MockHttp.Responses; namespace MockHttp.Json; @@ -19,7 +21,7 @@ public static class ResponseBuilderExtensions /// The optional JSON adapter. When null uses the default adapter. /// The builder to continue chaining additional behaviors. /// Thrown when is . - public static IResponseBuilder JsonBody(this IResponseBuilder builder, T jsonContent, Encoding? encoding = null, IJsonAdapter? adapter = null) + public static IWithContentResult JsonBody(this IWithContent builder, T jsonContent, Encoding? encoding = null, IJsonAdapter? adapter = null) { return builder.JsonBody(_ => jsonContent, encoding, adapter); } @@ -33,7 +35,7 @@ public static IResponseBuilder JsonBody(this IResponseBuilder builder, T json /// The optional JSON adapter. When null uses the default adapter. /// The builder to continue chaining additional behaviors. /// Thrown when or is . - public static IResponseBuilder JsonBody(this IResponseBuilder builder, Func jsonContentFactory, Encoding? encoding = null, IJsonAdapter? adapter = null) + public static IWithContentResult JsonBody(this IWithContent builder, Func jsonContentFactory, Encoding? encoding = null, IJsonAdapter? adapter = null) { if (jsonContentFactory is null) { diff --git a/src/MockHttp.Json/Constants.cs b/src/MockHttp.Json/MediaTypes.cs similarity index 100% rename from src/MockHttp.Json/Constants.cs rename to src/MockHttp.Json/MediaTypes.cs diff --git a/src/MockHttp.Json/Newtonsoft/ResponseBuilderExtensions.cs b/src/MockHttp.Json/Newtonsoft/ResponseBuilderExtensions.cs index 3c8b0d81..2d81eabd 100644 --- a/src/MockHttp.Json/Newtonsoft/ResponseBuilderExtensions.cs +++ b/src/MockHttp.Json/Newtonsoft/ResponseBuilderExtensions.cs @@ -1,4 +1,6 @@ using System.Text; +using MockHttp.Language.Flow.Response; +using MockHttp.Language.Response; using MockHttp.Responses; using Newtonsoft.Json; @@ -18,9 +20,9 @@ public static class ResponseBuilderExtensions /// The optional JSON serializer settings. When null uses the default serializer settings. /// The builder to continue chaining additional behaviors. /// Thrown when is . - public static IResponseBuilder JsonBody + public static IWithContentResult JsonBody ( - this IResponseBuilder builder, + this IWithContent builder, T jsonContent, Encoding? encoding = null, JsonSerializerSettings? serializerSettings = null @@ -38,9 +40,9 @@ public static IResponseBuilder JsonBody /// The optional JSON serializer settings. When null uses the default serializer settings. /// The builder to continue chaining additional behaviors. /// Thrown when or is . - public static IResponseBuilder JsonBody + public static IWithContentResult JsonBody ( - this IResponseBuilder builder, + this IWithContent builder, Func jsonContentFactory, Encoding? encoding = null, JsonSerializerSettings? serializerSettings = null diff --git a/src/MockHttp.Json/RespondsExtensions.cs b/src/MockHttp.Json/RespondsExtensions.cs index 6d1af5a2..9b54e9d3 100644 --- a/src/MockHttp.Json/RespondsExtensions.cs +++ b/src/MockHttp.Json/RespondsExtensions.cs @@ -3,6 +3,8 @@ using System.Text; using MockHttp.Language; using MockHttp.Language.Flow; +using MockHttp.Language.Flow.Response; +using MockHttp.Language.Response; namespace MockHttp.Json; @@ -133,15 +135,25 @@ public static TResult RespondJson(this IResponds responds, throw new ArgumentNullException(nameof(responds)); } - MediaTypeHeaderValue mt = mediaType ?? MediaTypeHeaderValue.Parse("application/json; charset=utf-8"); - Encoding? enc = mt.CharSet is not null - ? Encoding.GetEncoding(mt.CharSet) + Encoding? enc = mediaType?.CharSet is not null + ? Encoding.GetEncoding(mediaType.CharSet) : null; - return responds.Respond(with => with - .StatusCode(statusCode) - .JsonBody(ctx => content(ctx.Request), enc, adapter) - .ContentType(mt) - ); + + IWithResponse With(IWithStatusCode with) + { + IWithContentResult builder = with + .StatusCode(statusCode) + .JsonBody(ctx => content(ctx.Request), enc, adapter); + + if (mediaType is not null) + { + return builder.ContentType(mediaType); + } + + return builder; + } + + return responds.Respond(with => With(with)); } /// diff --git a/src/MockHttp.Json/SystemTextJson/ResponseBuilderExtensions.cs b/src/MockHttp.Json/SystemTextJson/ResponseBuilderExtensions.cs index 54da5a40..4e073598 100644 --- a/src/MockHttp.Json/SystemTextJson/ResponseBuilderExtensions.cs +++ b/src/MockHttp.Json/SystemTextJson/ResponseBuilderExtensions.cs @@ -1,5 +1,7 @@ using System.Text; using System.Text.Json; +using MockHttp.Language.Flow.Response; +using MockHttp.Language.Response; using MockHttp.Responses; namespace MockHttp.Json.SystemTextJson; @@ -18,9 +20,9 @@ public static class ResponseBuilderExtensions /// The optional JSON serializer options. When null uses the default serializer settings. /// The builder to continue chaining additional behaviors. /// Thrown when is . - public static IResponseBuilder JsonBody + public static IWithContentResult JsonBody ( - this IResponseBuilder builder, + this IWithContent builder, T jsonContent, Encoding? encoding = null, JsonSerializerOptions? serializerOptions = null @@ -38,9 +40,9 @@ public static IResponseBuilder JsonBody /// The optional JSON serializer options. When null uses the default serializer settings. /// The builder to continue chaining additional behaviors. /// Thrown when or is . - public static IResponseBuilder JsonBody + public static IWithContentResult JsonBody ( - this IResponseBuilder builder, + this IWithContent builder, Func jsonContentFactory, Encoding? encoding = null, JsonSerializerOptions? serializerOptions = null diff --git a/src/MockHttp/DeprecationWarnings.cs b/src/MockHttp/DeprecationWarnings.cs new file mode 100644 index 00000000..9d2179ee --- /dev/null +++ b/src/MockHttp/DeprecationWarnings.cs @@ -0,0 +1,7 @@ +namespace MockHttp; + +internal static class DeprecationWarnings +{ + public const string RespondsExtensions = "Obsolete, will be removed in next major release. Use the fluent .Respond(with => with.StatusCode(..).Body(..)) fluent API."; + public const string RespondsTimeoutExtensions = "Obsolete, will be removed in next major release. Use the fluent .Respond(with => with.Timeout(..)) fluent API."; +} diff --git a/src/MockHttp/Extensions/IRespondsExtensions.cs b/src/MockHttp/Extensions/IRespondsExtensions.cs index b5155d59..997bbe70 100644 --- a/src/MockHttp/Extensions/IRespondsExtensions.cs +++ b/src/MockHttp/Extensions/IRespondsExtensions.cs @@ -3,6 +3,7 @@ using System.Text; using MockHttp.Language; using MockHttp.Language.Flow; +using MockHttp.Language.Flow.Response; using MockHttp.Responses; namespace MockHttp; @@ -76,6 +77,7 @@ internal static TResult RespondUsing(this IResponds /// /// /// The status code response for given request. + [Obsolete(DeprecationWarnings.RespondsExtensions, false)] public static TResult Respond(this IResponds responds, HttpStatusCode statusCode) where TResult : IResponseResult { @@ -87,6 +89,7 @@ public static TResult Respond(this IResponds responds, HttpSta /// /// /// The response content. + [Obsolete(DeprecationWarnings.RespondsExtensions, false)] public static TResult Respond(this IResponds responds, string content) where TResult : IResponseResult { @@ -99,6 +102,7 @@ public static TResult Respond(this IResponds responds, string /// /// The status code response for given request. /// The response content. + [Obsolete(DeprecationWarnings.RespondsExtensions, false)] public static TResult Respond(this IResponds responds, HttpStatusCode statusCode, string content) where TResult : IResponseResult { @@ -111,6 +115,7 @@ public static TResult Respond(this IResponds responds, HttpSta /// /// The response content. /// The media type. + [Obsolete(DeprecationWarnings.RespondsExtensions, false)] public static TResult Respond(this IResponds responds, string content, string mediaType) where TResult : IResponseResult { @@ -124,6 +129,7 @@ public static TResult Respond(this IResponds responds, string /// The status code response for given request. /// The response content. /// The media type. + [Obsolete(DeprecationWarnings.RespondsExtensions, false)] public static TResult Respond(this IResponds responds, HttpStatusCode statusCode, string content, string mediaType) where TResult : IResponseResult { @@ -136,6 +142,7 @@ public static TResult Respond(this IResponds responds, HttpSta /// /// The response content. /// The media type. + [Obsolete(DeprecationWarnings.RespondsExtensions, false)] public static TResult Respond(this IResponds responds, string content, MediaTypeHeaderValue mediaType) where TResult : IResponseResult { @@ -149,6 +156,7 @@ public static TResult Respond(this IResponds responds, string /// The status code response for given request. /// The response content. /// The media type. + [Obsolete(DeprecationWarnings.RespondsExtensions, false)] public static TResult Respond(this IResponds responds, HttpStatusCode statusCode, string content, MediaTypeHeaderValue mediaType) where TResult : IResponseResult { @@ -160,7 +168,7 @@ public static TResult Respond(this IResponds responds, HttpSta return responds.Respond(with => with .StatusCode(statusCode) .Body(content) - .ContentType(mediaType ?? ResponseBuilder.DefaultMediaType) + .ContentType(mediaType ?? new MediaTypeHeaderValue(MediaTypes.DefaultMediaType)) ); } @@ -171,6 +179,7 @@ public static TResult Respond(this IResponds responds, HttpSta /// The response content. /// The encoding. /// The media type. + [Obsolete(DeprecationWarnings.RespondsExtensions, false)] public static TResult Respond(this IResponds responds, string content, Encoding encoding, string mediaType) where TResult : IResponseResult { @@ -185,10 +194,11 @@ public static TResult Respond(this IResponds responds, string /// The response content. /// The encoding. /// The media type. + [Obsolete(DeprecationWarnings.RespondsExtensions, false)] public static TResult Respond(this IResponds responds, HttpStatusCode statusCode, string content, Encoding encoding, string mediaType) where TResult : IResponseResult { - return responds.Respond(statusCode, content, new MediaTypeHeaderValue(mediaType ?? ResponseBuilder.DefaultMediaType.MediaType) { CharSet = (encoding ?? ResponseBuilder.DefaultWebEncoding).WebName }); + return responds.Respond(statusCode, content, new MediaTypeHeaderValue(mediaType ?? MediaTypes.DefaultMediaType) { CharSet = (encoding ?? ResponseBuilder.DefaultWebEncoding).WebName }); } /// @@ -196,6 +206,7 @@ public static TResult Respond(this IResponds responds, HttpSta /// /// /// The response stream. + [Obsolete(DeprecationWarnings.RespondsExtensions, false)] public static TResult Respond(this IResponds responds, Stream streamContent) where TResult : IResponseResult { @@ -208,6 +219,7 @@ public static TResult Respond(this IResponds responds, Stream /// /// The response stream. /// The media type. + [Obsolete(DeprecationWarnings.RespondsExtensions, false)] public static TResult Respond(this IResponds responds, Stream streamContent, string mediaType) where TResult : IResponseResult { @@ -220,6 +232,7 @@ public static TResult Respond(this IResponds responds, Stream /// /// The status code response for given request. /// The response stream. + [Obsolete(DeprecationWarnings.RespondsExtensions, false)] public static TResult Respond(this IResponds responds, HttpStatusCode statusCode, Stream streamContent) where TResult : IResponseResult { @@ -233,6 +246,7 @@ public static TResult Respond(this IResponds responds, HttpSta /// The status code response for given request. /// The response stream. /// The media type. + [Obsolete(DeprecationWarnings.RespondsExtensions, false)] public static TResult Respond(this IResponds responds, HttpStatusCode statusCode, Stream streamContent, string mediaType) where TResult : IResponseResult { @@ -245,6 +259,7 @@ public static TResult Respond(this IResponds responds, HttpSta /// /// The response stream. /// The media type. + [Obsolete(DeprecationWarnings.RespondsExtensions, false)] public static TResult Respond(this IResponds responds, Stream streamContent, MediaTypeHeaderValue mediaType) where TResult : IResponseResult { @@ -258,6 +273,7 @@ public static TResult Respond(this IResponds responds, Stream /// The status code response for given request. /// The response stream. /// The media type. + [Obsolete(DeprecationWarnings.RespondsExtensions, false)] public static TResult Respond(this IResponds responds, HttpStatusCode statusCode, Stream streamContent, MediaTypeHeaderValue mediaType) where TResult : IResponseResult { @@ -274,7 +290,7 @@ public static TResult Respond(this IResponds responds, HttpSta return responds.Respond(with => with .StatusCode(statusCode) .Body(streamContent) - .ContentType(mediaType ?? ResponseBuilder.DefaultMediaType) + .ContentType(mediaType ?? new MediaTypeHeaderValue(MediaTypes.DefaultMediaType)) ); } @@ -285,6 +301,7 @@ public static TResult Respond(this IResponds responds, HttpSta /// The status code response for given request. /// The factory to create the response stream with. /// The media type. + [Obsolete(DeprecationWarnings.RespondsExtensions, false)] public static TResult Respond(this IResponds responds, HttpStatusCode statusCode, Func streamContent, MediaTypeHeaderValue mediaType) where TResult : IResponseResult { @@ -301,7 +318,7 @@ public static TResult Respond(this IResponds responds, HttpSta return responds.Respond(with => with .StatusCode(statusCode) .Body(streamContent) - .ContentType(mediaType ?? ResponseBuilder.DefaultMediaType) + .ContentType(mediaType ?? new MediaTypeHeaderValue(MediaTypes.DefaultMediaType)) ); } @@ -310,6 +327,7 @@ public static TResult Respond(this IResponds responds, HttpSta /// /// /// The response content. + [Obsolete(DeprecationWarnings.RespondsExtensions, false)] public static T Respond(this IResponds responds, HttpContent content) where T : IResponseResult { @@ -322,6 +340,7 @@ public static T Respond(this IResponds responds, HttpContent content) /// /// The status code response for given request. /// The response content. + [Obsolete(DeprecationWarnings.RespondsExtensions, false)] public static TResult Respond(this IResponds responds, HttpStatusCode statusCode, HttpContent content) where TResult : IResponseResult { @@ -346,6 +365,7 @@ public static TResult Respond(this IResponds responds, HttpSta /// Specifies to throw a simulating a HTTP request timeout. /// /// + [Obsolete(DeprecationWarnings.RespondsTimeoutExtensions, false)] public static TResult TimesOut(this IResponds responds) where TResult : IResponseResult { @@ -357,6 +377,7 @@ public static TResult TimesOut(this IResponds responds) /// /// /// The number of milliseconds after which the timeout occurs. + [Obsolete(DeprecationWarnings.RespondsTimeoutExtensions, false)] public static TResult TimesOutAfter(this IResponds responds, int timeoutAfterMilliseconds) where TResult : IResponseResult { @@ -368,6 +389,7 @@ public static TResult TimesOutAfter(this IResponds responds, i /// /// /// The time after which the timeout occurs. + [Obsolete(DeprecationWarnings.RespondsTimeoutExtensions, false)] public static TResult TimesOutAfter(this IResponds responds, TimeSpan timeoutAfter) where TResult : IResponseResult { diff --git a/src/MockHttp/Extensions/ListExtensions.cs b/src/MockHttp/Extensions/ListExtensions.cs new file mode 100644 index 00000000..33dad40d --- /dev/null +++ b/src/MockHttp/Extensions/ListExtensions.cs @@ -0,0 +1,38 @@ +namespace MockHttp; + +internal static class ListExtensions +{ + internal static void Replace(this IList list, T instance) + { + if (list is null) + { + throw new ArgumentNullException(nameof(list)); + } + + if (instance is null) + { + throw new ArgumentNullException(nameof(instance)); + } + + bool IsExact(Type thisType) + { + return thisType == instance.GetType(); + } + + int[] existing = list + .Select((item, index) => new { item, index }) + .Where(x => + { + Type thisType = x.item?.GetType(); + return IsExact(thisType); + }) + .Select(x => x.index) + .ToArray(); + for (int i = existing.Length - 1; i >= 0; i--) + { + list.RemoveAt(existing[i]); + } + + list.Add(instance); + } +} diff --git a/src/MockHttp/Extensions/ResponseBuilderExtensions.cs b/src/MockHttp/Extensions/ResponseBuilderExtensions.cs index f92eec8a..058e039f 100644 --- a/src/MockHttp/Extensions/ResponseBuilderExtensions.cs +++ b/src/MockHttp/Extensions/ResponseBuilderExtensions.cs @@ -3,6 +3,8 @@ using System.Net.Http.Headers; using System.Text; using MockHttp.Http; +using MockHttp.Language.Flow.Response; +using MockHttp.Language.Response; using MockHttp.Responses; namespace MockHttp; @@ -20,30 +22,11 @@ public static class ResponseBuilderExtensions /// The builder to continue chaining additional behaviors. /// Thrown when is . /// Thrown when is less than 100. - public static IResponseBuilder StatusCode(this IResponseBuilder builder, int statusCode) + public static IWithStatusCodeResult StatusCode(this IWithStatusCode builder, int statusCode) { return builder.StatusCode((HttpStatusCode)statusCode); } - /// - /// Sets the status code for the response. - /// - /// The builder. - /// The status code to return with the response. - /// The builder to continue chaining additional behaviors. - /// Thrown when is . - /// Thrown when is less than 100. - public static IResponseBuilder StatusCode(this IResponseBuilder builder, HttpStatusCode statusCode) - { - if (builder is null) - { - throw new ArgumentNullException(nameof(builder)); - } - - builder.Behaviors.Add(new StatusCodeBehavior(statusCode)); - return builder; - } - /// /// Sets the plain text content for the response. /// @@ -52,7 +35,7 @@ public static IResponseBuilder StatusCode(this IResponseBuilder builder, HttpSta /// The optional encoding to use when encoding the text. /// The builder to continue chaining additional behaviors. /// Thrown when or is . - public static IResponseBuilder Body(this IResponseBuilder builder, string content, Encoding? encoding = null) + public static IWithContentResult Body(this IWithContent builder, string content, Encoding? encoding = null) { if (content is null) { @@ -69,7 +52,7 @@ public static IResponseBuilder Body(this IResponseBuilder builder, string conten /// The binary content. /// The builder to continue chaining additional behaviors. /// Thrown when or is . - public static IResponseBuilder Body(this IResponseBuilder builder, byte[] content) + public static IWithContentResult Body(this IWithContent builder, byte[] content) { return builder.Body(content, 0, content.Length); } @@ -84,7 +67,7 @@ public static IResponseBuilder Body(this IResponseBuilder builder, byte[] conten /// The builder to continue chaining additional behaviors. /// Thrown when or is . /// Thrown when or exceeds beyond end of array. - public static IResponseBuilder Body(this IResponseBuilder builder, byte[] content, int offset, int count) + public static IWithContentResult Body(this IWithContent builder, byte[] content, int offset, int count) { if (offset < 0 || offset > content.Length) { @@ -108,7 +91,7 @@ public static IResponseBuilder Body(this IResponseBuilder builder, byte[] conten /// The builder to continue chaining additional behaviors. /// Thrown when or is . /// Thrown when the stream does not support reading. - public static IResponseBuilder Body(this IResponseBuilder builder, Stream content) + public static IWithContentResult Body(this IWithContent builder, Stream content) { if (content is null) { @@ -140,7 +123,7 @@ public static IResponseBuilder Body(this IResponseBuilder builder, Stream conten /// The factory returning a new on each invocation containing the binary content. /// The builder to continue chaining additional behaviors. /// Thrown when or is . - public static IResponseBuilder Body(this IResponseBuilder builder, Func streamFactory) + public static IWithContentResult Body(this IWithContent builder, Func streamFactory) { if (streamFactory is null) { @@ -159,29 +142,6 @@ public static IResponseBuilder Body(this IResponseBuilder builder, Func }); } - /// - /// Sets the content for the response using a factory returning a new on each invocation. - /// - /// The builder. - /// The factory returning a new instance of on each invocation. - /// The builder to continue chaining additional behaviors. - /// Thrown when or is . - public static IResponseBuilder Body(this IResponseBuilder builder, Func> httpContentFactory) - { - if (builder is null) - { - throw new ArgumentNullException(nameof(builder)); - } - - if (httpContentFactory is null) - { - throw new ArgumentNullException(nameof(httpContentFactory)); - } - - builder.Behaviors.Add(new HttpContentBehavior(httpContentFactory)); - return builder; - } - /// /// Sets the content type for the response. Will be ignored if no content is set. /// @@ -190,7 +150,7 @@ public static IResponseBuilder Body(this IResponseBuilder builder, FuncThe optional encoding. /// The builder to continue chaining additional behaviors. /// Thrown when or is . - public static IResponseBuilder ContentType(this IResponseBuilder builder, string contentType, Encoding? encoding = null) + public static IWithHeadersResult ContentType(this IWithContentType builder, string contentType, Encoding? encoding = null) { if (contentType is null) { @@ -206,23 +166,6 @@ public static IResponseBuilder ContentType(this IResponseBuilder builder, string return builder.ContentType(mediaType); } - /// - /// Sets the content type for the response. Will be ignored if no content is set. - /// - /// The builder. - /// The media type. - /// The builder to continue chaining additional behaviors. - /// Thrown when or is . - public static IResponseBuilder ContentType(this IResponseBuilder builder, MediaTypeHeaderValue mediaType) - { - if (mediaType is null) - { - throw new ArgumentNullException(nameof(mediaType)); - } - - return builder.Header("Content-Type", mediaType.ToString()); - } - /// /// Adds a HTTP header value. /// @@ -231,7 +174,7 @@ public static IResponseBuilder ContentType(this IResponseBuilder builder, MediaT /// The header value. /// The builder to continue chaining additional behaviors. /// Thrown when or is . - public static IResponseBuilder Header(this IResponseBuilder builder, string name, string value) + public static IWithHeadersResult Header(this IWithHeaders builder, string name, string value) { return builder.Header(name, new[] { value }); } @@ -244,7 +187,7 @@ public static IResponseBuilder Header(this IResponseBuilder builder, string name /// The header values. /// The builder to continue chaining additional behaviors. /// Thrown when or is . - public static IResponseBuilder Header(this IResponseBuilder builder, string name, IEnumerable values) + public static IWithHeadersResult Header(this IWithHeaders builder, string name, IEnumerable values) { if (name is null) { @@ -255,31 +198,8 @@ public static IResponseBuilder Header(this IResponseBuilder builder, string name } /// - /// Adds HTTP headers. - /// - /// The builder. - /// The headers. - /// The builder to continue chaining additional behaviors. - /// Thrown when or is . - public static IResponseBuilder Headers(this IResponseBuilder builder, IEnumerable>> headers) - { - if (builder is null) - { - throw new ArgumentNullException(nameof(builder)); - } - - if (headers is null) - { - throw new ArgumentNullException(nameof(headers)); - } - - builder.Behaviors.Add(new HttpHeaderBehavior(headers)); - return builder; - } - - /// - /// Specifies to throw a simulating a HTTP request timeout. - /// Note: the response is short-circuited when running outside of the HTTP server. To simulate server timeouts, use .StatusCode(HttpStatusCode.RequestTimeout) instead. + /// Specifies to throw a simulating a HTTP client request timeout. + /// Note: the response is short-circuited when running outside of the HTTP mock server. To simulate server timeouts, use .StatusCode(HttpStatusCode.RequestTimeout) instead. /// /// The builder. /// The builder to continue chaining additional behaviors. @@ -290,8 +210,8 @@ public static IResponseBuilder TimesOut(this IResponseBuilder builder) } /// - /// Specifies to throw a after a specified amount of time, simulating a HTTP request timeout. - /// Note: the response is short-circuited when running outside of the HTTP server. To simulate server timeouts, use .StatusCode(HttpStatusCode.RequestTimeout) instead. + /// Specifies to throw a after a specified amount of time, simulating a HTTP client request timeout. + /// Note: the response is short-circuited when running outside of the HTTP mock server. To simulate server timeouts, use .StatusCode(HttpStatusCode.RequestTimeout) instead. /// /// The builder. /// The time after which the timeout occurs. @@ -304,7 +224,7 @@ public static IResponseBuilder TimesOutAfter(this IResponseBuilder builder, Time throw new ArgumentNullException(nameof(builder)); } - builder.Behaviors.Add(new TimeoutBehavior(timeoutAfter)); + builder.Behaviors.Replace(new TimeoutBehavior(timeoutAfter)); return builder; } @@ -315,14 +235,15 @@ public static IResponseBuilder TimesOutAfter(this IResponseBuilder builder, Time /// The network latency to simulate. /// The builder to continue chaining additional behaviors. /// Thrown when or is . - public static IResponseBuilder Latency(this IResponseBuilder builder, NetworkLatency latency) + public static IWithResponse Latency(this IWithResponse builder, NetworkLatency latency) { if (builder is null) { throw new ArgumentNullException(nameof(builder)); } - return builder.RegisterBehavior(new NetworkLatencyBehavior(latency)); + builder.Behaviors.Replace(new NetworkLatencyBehavior(latency)); + return builder; } /// @@ -332,7 +253,7 @@ public static IResponseBuilder Latency(this IResponseBuilder builder, NetworkLat /// The network latency to simulate. /// The builder to continue chaining additional behaviors. /// Thrown when or is . - public static IResponseBuilder Latency(this IResponseBuilder builder, Func latency) + public static IWithResponse Latency(this IWithResponse builder, Func latency) { return builder.Latency(latency()); } diff --git a/src/MockHttp/IResponseBuilder.cs b/src/MockHttp/IResponseBuilder.cs index 984ec5cd..f4883509 100644 --- a/src/MockHttp/IResponseBuilder.cs +++ b/src/MockHttp/IResponseBuilder.cs @@ -1,5 +1,5 @@ using System.ComponentModel; -using MockHttp.Responses; +using MockHttp.Language.Response; namespace MockHttp; @@ -7,10 +7,11 @@ namespace MockHttp; /// A builder to compose HTTP responses via a behavior pipeline. /// [EditorBrowsable(EditorBrowsableState.Never)] -public interface IResponseBuilder : IFluentInterface +public interface IResponseBuilder + : IWithResponse, + IWithStatusCode, + IWithContent, + IWithHeaders, + IFluentInterface { - /// - /// Gets a list of behaviors to modify the HTTP response returned. - /// - IList Behaviors { get; } } diff --git a/src/MockHttp/Language/Flow/Response/IWithContentResult.cs b/src/MockHttp/Language/Flow/Response/IWithContentResult.cs new file mode 100644 index 00000000..1956bc2f --- /dev/null +++ b/src/MockHttp/Language/Flow/Response/IWithContentResult.cs @@ -0,0 +1,15 @@ +using System.ComponentModel; +using MockHttp.Language.Response; + +namespace MockHttp.Language.Flow.Response; + +/// +/// Implements the fluent API. +/// +[EditorBrowsable(EditorBrowsableState.Never)] +public interface IWithContentResult + : IWithContentType, + IWithHeaders, + IFluentInterface +{ +} diff --git a/src/MockHttp/Language/Flow/Response/IWithHeadersResult.cs b/src/MockHttp/Language/Flow/Response/IWithHeadersResult.cs new file mode 100644 index 00000000..cadb2e85 --- /dev/null +++ b/src/MockHttp/Language/Flow/Response/IWithHeadersResult.cs @@ -0,0 +1,14 @@ +using System.ComponentModel; +using MockHttp.Language.Response; + +namespace MockHttp.Language.Flow.Response; + +/// +/// Implements the fluent API. +/// +[EditorBrowsable(EditorBrowsableState.Never)] +public interface IWithHeadersResult + : IWithHeaders, + IFluentInterface +{ +} diff --git a/src/MockHttp/Language/Flow/Response/IWithStatusCodeResult.cs b/src/MockHttp/Language/Flow/Response/IWithStatusCodeResult.cs new file mode 100644 index 00000000..5947cecf --- /dev/null +++ b/src/MockHttp/Language/Flow/Response/IWithStatusCodeResult.cs @@ -0,0 +1,15 @@ +using System.ComponentModel; +using MockHttp.Language.Response; + +namespace MockHttp.Language.Flow.Response; + +/// +/// Implements the fluent API. +/// +[EditorBrowsable(EditorBrowsableState.Never)] +public interface IWithStatusCodeResult + : IWithContent, + IWithHeaders, + IFluentInterface +{ +} diff --git a/src/MockHttp/Language/Flow/Response/ResponseBuilder.cs b/src/MockHttp/Language/Flow/Response/ResponseBuilder.cs new file mode 100644 index 00000000..816b22c8 --- /dev/null +++ b/src/MockHttp/Language/Flow/Response/ResponseBuilder.cs @@ -0,0 +1,89 @@ +#nullable enable +using System.Net; +using System.Net.Http.Headers; +using System.Text; +using MockHttp.Http; +using MockHttp.Responses; + +namespace MockHttp.Language.Flow.Response; + +internal class ResponseBuilder + : IResponseStrategy, + IResponseBuilder, + IWithStatusCodeResult, + IWithContentResult, + IWithHeadersResult +{ + internal static readonly Encoding DefaultWebEncoding = Encoding.UTF8; + + private static readonly Func> EmptyHttpContentFactory = _ => Task.FromResult(new EmptyContent()); + private static readonly Dictionary BehaviorPriority = new() + { + { typeof(TimeoutBehavior), 0 }, + { typeof(NetworkLatencyBehavior), 1 }, + { typeof(StatusCodeBehavior), 2 }, + { typeof(HttpContentBehavior), 3 }, + { typeof(HttpHeaderBehavior), 4 } + }; + + async Task IResponseStrategy.ProduceResponseAsync(MockHttpRequestContext requestContext, CancellationToken cancellationToken) + { + static Task Seed(MockHttpRequestContext requestContext, HttpResponseMessage responseMessage, CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + + var response = new HttpResponseMessage + { + RequestMessage = requestContext.Request + }; + + await Behaviors + .ToArray() // clone to array prevent the list from changing while executing. + .OrderBy(behavior => BehaviorPriority.TryGetValue(behavior.GetType(), out int priority) ? priority : int.MaxValue) + .Reverse() + .Aggregate((ResponseHandlerDelegate)Seed, + (next, pipeline) => (context, message, ct) => pipeline.HandleAsync(context, message, next, ct) + )(requestContext, response, cancellationToken); + + return response; + } + + /// + public IList Behaviors { get; } = new List(); + + /// + public IWithStatusCodeResult StatusCode(HttpStatusCode statusCode) + { + Behaviors.Add(new StatusCodeBehavior(statusCode)); + return this; + } + + /// + public IWithContentResult Body(Func> httpContentFactory) + { + Behaviors.Add(new HttpContentBehavior(httpContentFactory)); + return this; + } + + /// + public IWithHeadersResult ContentType(MediaTypeHeaderValue mediaType) + { + return Headers(new HttpHeadersCollection { { "Content-Type", new[] { mediaType.ToString() } } }); + } + + /// + public IWithHeadersResult Headers(IEnumerable>> headers) + { + // If no content when adding headers, we force empty content so that content headers can still be set. + if (!Behaviors.OfType().Any()) + { + Body(EmptyHttpContentFactory); + } + + Behaviors.Add(new HttpHeaderBehavior(headers)); + return this; + } +} + +#nullable restore diff --git a/src/MockHttp/Language/Response/IWithContent.cs b/src/MockHttp/Language/Response/IWithContent.cs new file mode 100644 index 00000000..c043ab36 --- /dev/null +++ b/src/MockHttp/Language/Response/IWithContent.cs @@ -0,0 +1,22 @@ +using System.ComponentModel; +using MockHttp.Language.Flow.Response; +using MockHttp.Responses; + +namespace MockHttp.Language.Response; + +/// +/// Defines the Body verb. +/// +[EditorBrowsable(EditorBrowsableState.Never)] +public interface IWithContent + : IWithResponse, + IFluentInterface +{ + /// + /// Sets the content for the response using a factory returning a new on each invocation. + /// + /// The factory returning a new instance of on each invocation. + /// The builder to continue chaining additional behaviors. + /// Thrown when is . + IWithContentResult Body(Func> httpContentFactory); +} diff --git a/src/MockHttp/Language/Response/IWithContentType.cs b/src/MockHttp/Language/Response/IWithContentType.cs new file mode 100644 index 00000000..232cbe92 --- /dev/null +++ b/src/MockHttp/Language/Response/IWithContentType.cs @@ -0,0 +1,22 @@ +using System.ComponentModel; +using System.Net.Http.Headers; +using MockHttp.Language.Flow.Response; + +namespace MockHttp.Language.Response; + +/// +/// Defines the ContentType verb. +/// +[EditorBrowsable(EditorBrowsableState.Never)] +public interface IWithContentType + : IWithResponse, + IFluentInterface +{ + /// + /// Sets the content type for the response. + /// + /// The media type. + /// The builder to continue chaining additional behaviors. + /// Thrown when is . + public IWithHeadersResult ContentType(MediaTypeHeaderValue mediaType); +} diff --git a/src/MockHttp/Language/Response/IWithHeaders.cs b/src/MockHttp/Language/Response/IWithHeaders.cs new file mode 100644 index 00000000..68177995 --- /dev/null +++ b/src/MockHttp/Language/Response/IWithHeaders.cs @@ -0,0 +1,21 @@ +using System.ComponentModel; +using MockHttp.Language.Flow.Response; + +namespace MockHttp.Language.Response; + +/// +/// Defines the Headers verb. +/// +[EditorBrowsable(EditorBrowsableState.Never)] +public interface IWithHeaders + : IWithResponse, + IFluentInterface +{ + /// + /// Adds HTTP headers. + /// + /// The headers. + /// The builder to continue chaining additional behaviors. + /// Thrown when is . + IWithHeadersResult Headers(IEnumerable>> headers); +} diff --git a/src/MockHttp/Language/Response/IWithResponse.cs b/src/MockHttp/Language/Response/IWithResponse.cs new file mode 100644 index 00000000..50b487f5 --- /dev/null +++ b/src/MockHttp/Language/Response/IWithResponse.cs @@ -0,0 +1,17 @@ +using System.ComponentModel; +using MockHttp.Responses; + +namespace MockHttp.Language.Response; + +/// +/// Describes a way to build a response via a behavior pipeline. +/// +[EditorBrowsable(EditorBrowsableState.Never)] +public interface IWithResponse + : IFluentInterface +{ + /// + /// Gets a list of behaviors to modify the HTTP response returned. + /// + IList Behaviors { get; } +} diff --git a/src/MockHttp/Language/Response/IWithStatusCode.cs b/src/MockHttp/Language/Response/IWithStatusCode.cs new file mode 100644 index 00000000..037725d5 --- /dev/null +++ b/src/MockHttp/Language/Response/IWithStatusCode.cs @@ -0,0 +1,22 @@ +using System.ComponentModel; +using System.Net; +using MockHttp.Language.Flow.Response; + +namespace MockHttp.Language.Response; + +/// +/// Defines the StatusCode verb. +/// +[EditorBrowsable(EditorBrowsableState.Never)] +public interface IWithStatusCode + : IWithContent, + IFluentInterface +{ + /// + /// Sets the status code for the response. + /// + /// The status code to return with the response. + /// The builder to continue chaining additional behaviors. + /// Thrown when is less than 100. + IWithStatusCodeResult StatusCode(HttpStatusCode statusCode); +} diff --git a/src/MockHttp/MediaTypes.cs b/src/MockHttp/MediaTypes.cs new file mode 100644 index 00000000..f69e1cb8 --- /dev/null +++ b/src/MockHttp/MediaTypes.cs @@ -0,0 +1,7 @@ +namespace MockHttp; + +internal static class MediaTypes +{ + internal const string TextPlain = "text/plain"; + internal const string DefaultMediaType = TextPlain; +} diff --git a/src/MockHttp/ResponseBuilder.cs b/src/MockHttp/ResponseBuilder.cs deleted file mode 100644 index 727ace92..00000000 --- a/src/MockHttp/ResponseBuilder.cs +++ /dev/null @@ -1,45 +0,0 @@ -#nullable enable -using System.Net.Http.Headers; -using System.Text; -using MockHttp.Responses; - -namespace MockHttp; - -internal class ResponseBuilder - : IResponseStrategy, - IResponseBuilder -{ - internal static readonly MediaTypeHeaderValue DefaultMediaType = new("text/plain"); - internal static readonly Encoding DefaultWebEncoding = Encoding.UTF8; - - private static readonly Dictionary BehaviorPriority = new() - { - { typeof(TimeoutBehavior), 0 }, - { typeof(NetworkLatencyBehavior), 1 }, - { typeof(StatusCodeBehavior), 2 }, - { typeof(HttpContentBehavior), 3 }, - { typeof(HttpHeaderBehavior), 4 } - }; - - async Task IResponseStrategy.ProduceResponseAsync(MockHttpRequestContext requestContext, CancellationToken cancellationToken) - { - static Task Seed(MockHttpRequestContext requestContext, HttpResponseMessage responseMessage, CancellationToken cancellationToken) => Task.CompletedTask; - - var response = new HttpResponseMessage(); - - await Behaviors - .ToArray() // clone to array prevent the list from changing while executing. - .OrderBy(behavior => BehaviorPriority.TryGetValue(behavior.GetType(), out int priority) ? priority : int.MaxValue) - .Reverse() - .Aggregate((ResponseHandlerDelegate)Seed, - (next, pipeline) => (context, message, ct) => pipeline.HandleAsync(context, message, next, ct) - )(requestContext, response, cancellationToken); - - return response; - } - - /// - public IList Behaviors { get; } = new List(); -} - -#nullable restore diff --git a/src/MockHttp/Responses/EmptyContent.cs b/src/MockHttp/Responses/EmptyContent.cs new file mode 100644 index 00000000..02f95175 --- /dev/null +++ b/src/MockHttp/Responses/EmptyContent.cs @@ -0,0 +1,47 @@ +#nullable enable +using System.Net; + +namespace MockHttp.Responses; + +internal class EmptyContent : HttpContent +{ + protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context) + { + return Task.CompletedTask; + } + + protected override bool TryComputeLength(out long length) + { + length = 0; + return true; + } + + protected override Task CreateContentReadStreamAsync() + { + return Task.FromResult(Stream.Null); + } + +#if NET5_0_OR_GREATER + protected override void SerializeToStream(Stream stream, TransportContext? context, CancellationToken cancellationToken) + { + } + + protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context, CancellationToken cancellationToken) + { + return cancellationToken.IsCancellationRequested ? Task.FromCanceled(cancellationToken) : SerializeToStreamAsync(stream, context); + } + + protected override Stream CreateContentReadStream(CancellationToken cancellationToken) + { + return Stream.Null; + } + + protected override Task CreateContentReadStreamAsync(CancellationToken cancellationToken) + { + return cancellationToken.IsCancellationRequested + ? Task.FromCanceled(cancellationToken) + : CreateContentReadStreamAsync(); + } +#endif +} +#nullable restore diff --git a/test/MockHttp.Tests/Extensions/ListExtensionsTests.cs b/test/MockHttp.Tests/Extensions/ListExtensionsTests.cs new file mode 100644 index 00000000..441bb5cb --- /dev/null +++ b/test/MockHttp.Tests/Extensions/ListExtensionsTests.cs @@ -0,0 +1,62 @@ +using FluentAssertions; +using Xunit; + +namespace MockHttp.Extensions; + +public class ListExtensionsTests +{ + private interface IFoo + { + } + + public class Foo : IFoo + { + public string Bar { get; set; } + } + + public class FooBar : IFoo + { + public string Baz { get; set; } + } + + public class Replace + { + [Fact] + public void Given_that_list_does_not_contain_instance_of_type_when_replacing_it_should_just_add() + { + var initial = new List() + { + new Foo(), + new Foo() + }; + var list = new List(initial); + var replaceWith = new FooBar(); + + // Act + list.Replace(replaceWith); + + // Assert + list.Should().HaveCount(3); + list.Should().StartWith(initial); + list.Should().EndWith(replaceWith); + } + + [Fact] + public void Given_that_list_contains_instances_of_type_when_replacing_it_should_replace() + { + var list = new List() + { + new Foo(), + new Foo(), + new Foo() + }; + var replaceWith = new Foo { Bar = "bar" }; + + // Act + list.Replace(replaceWith); + + // Assert + list.Should().ContainSingle().Which.Should().BeSameAs(replaceWith); + } + } +} diff --git a/test/MockHttp.Tests/Responses/EmptyContentTests.cs b/test/MockHttp.Tests/Responses/EmptyContentTests.cs new file mode 100644 index 00000000..154ab449 --- /dev/null +++ b/test/MockHttp.Tests/Responses/EmptyContentTests.cs @@ -0,0 +1,96 @@ +using System.Net; +using FluentAssertions; +using Moq; +using Xunit; + +namespace MockHttp.Responses; + +public class EmptyContentTests +{ + private readonly EmptyContent _sut; + + public EmptyContentTests() + { + _sut = new EmptyContent(); + } + + [Fact] + public async Task When_reading_string_it_should_return_empty() + { + (await _sut.ReadAsStringAsync()).Should().BeEmpty(); + } + + [Fact] + public async Task When_reading_stream_async_it_should_return_empty() + { + // Act +#if NETCOREAPP3_1_OR_GREATER + await using Stream stream = await _sut.ReadAsStreamAsync(); +#else + using Stream stream = await _sut.ReadAsStreamAsync(); +#endif + + // Assert + stream.Should().NotBeNull(); + stream.Position.Should().Be(0); + stream.Length.Should().Be(0); + } + +#if NET5_0_OR_GREATER + + [Fact] + public async Task When_reading_stream_sync_it_should_return_empty() + { + // Act + // ReSharper disable once MethodHasAsyncOverload + await using Stream stream = _sut.ReadAsStream(); + + // Assert + stream.Should().NotBeNull(); + stream.Position.Should().Be(0); + stream.Length.Should().Be(0); + } + + [Fact] + public async Task Given_that_cancellation_token_is_cancelled_when_reading_stream_it_should_throw() + { + // Act + Func act = () => + { + using var cts = new CancellationTokenSource(); + cts.Cancel(); + return _sut.ReadAsStreamAsync(cts.Token); + }; + + // Assert + await act.Should().ThrowExactlyAsync(); + } + + [Fact] + public void When_copying_sync_it_should_not_throw() + { + Action act = () => + { + using var ms = new MemoryStream(); + // ReSharper disable once MethodHasAsyncOverload + _sut.CopyTo(ms, Mock.Of(), CancellationToken.None); + ms.Length.Should().Be(0); + }; + + act.Should().NotThrow(); + } +#endif + + [Fact] + public async Task When_copying_async_it_should_not_throw() + { + Func act = async () => + { + using var ms = new MemoryStream(); + await _sut.CopyToAsync(ms); + ms.Length.Should().Be(0); + }; + + await act.Should().NotThrowAsync(); + } +} From 4d1a2ec7edd2b90c8b103d3c682eb0b55906fbfa Mon Sep 17 00:00:00 2001 From: skwasjer Date: Thu, 26 May 2022 10:37:40 +0200 Subject: [PATCH 5/6] test: add coverage for new fluent response API --- .../Newtonsoft/ResponseBuilderExtensions.cs | 3 +- .../ResponseBuilderExtensions.cs | 3 +- .../Extensions/IRespondsExtensions.cs | 2 +- src/MockHttp/Extensions/ListExtensions.cs | 9 +- .../Extensions/ResponseBuilderExtensions.cs | 165 +++++++++++++----- .../Language/Flow/Response/ResponseBuilder.cs | 24 ++- .../Language/Response/IWithHeaders.cs | 1 + src/MockHttp/Responses/HttpHeaderBehavior.cs | 13 +- .../Newtonsoft/Specs/Response/JsonBodySpec.cs | 9 + .../Response/JsonBodyWithDateTimeOffset.cs | 9 + .../Response/JsonBodyWithEncodingSpec.cs | 9 + .../Specs/Response/JsonBodyWithNullSpec.cs | 9 + .../Specs/Response/JsonBodyWithStringSpec.cs | 9 + .../Specs/Response/JsonBodySpec.cs | 21 +++ .../Response/JsonBodyWithDateTimeOffset.cs | 21 +++ .../Response/JsonBodyWithEncodingSpec.cs | 24 +++ .../Specs/Response/JsonBodyWithNullSpec.cs | 21 +++ .../Specs/Response/JsonBodyWithStringSpec.cs | 21 +++ .../Specs/Response/JsonBodySpec.cs | 9 + .../Response/JsonBodyWithDateTimeOffset.cs | 9 + .../Response/JsonBodyWithEncodingSpec.cs | 9 + .../Specs/Response/JsonBodyWithNullSpec.cs | 9 + .../Specs/Response/JsonBodyWithStringSpec.cs | 9 + ...HttpResponseMessageAssertionsExtensions.cs | 32 +++- .../Specs/GuardedResponseSpec.cs | 19 ++ test/MockHttp.Testing/Specs/ResponseSpec.cs | 64 +++++++ .../Extensions/IRespondsExtensionsTests.cs | 3 + .../Language/Flow/Response/ByteBodySpec.cs | 22 +++ .../Flow/Response/ClientTimeoutSpec.cs | 26 +++ .../Response/ContentTypeWithEncodingSpec.cs | 23 +++ .../Flow/Response/ContentTypeWithNullSpec.cs | 38 ++++ .../ContentTypeWithWebEncodingSpec.cs | 22 +++ .../ContentTypeWithoutEncodingSpec.cs | 23 +++ .../Response/FuncStreamBodyCannotReadSpec.cs | 30 ++++ .../Flow/Response/FuncStreamBodySpec.cs | 19 ++ .../Response/FuncStreamBodyWithNullSpec.cs | 21 +++ .../Language/Flow/Response/HeaderSpec.cs | 48 +++++ .../Flow/Response/HeaderWithoutNameSpec.cs | 21 +++ .../Flow/Response/HeaderWithoutValueSpec.cs | 22 +++ .../Language/Flow/Response/LatencySpec.cs | 27 +++ .../Flow/Response/PartialByteBodySpec.cs | 19 ++ .../PartialByteBodyWithCountOutOfRangeSpec.cs | 36 ++++ ...PartialByteBodyWithOffsetOutOfRangeSpec.cs | 36 ++++ .../Flow/Response/ServerTimeoutSpec.cs | 28 +++ .../Flow/Response/StatusCodeOutOfRangeSpec.cs | 37 ++++ .../Language/Flow/Response/StatusCodeSpec.cs | 35 ++++ .../Response/StatusCodeWithStringBodySpec.cs | 24 +++ .../Flow/Response/StreamBodyCannotReadSpec.cs | 31 ++++ .../Flow/Response/StreamBodyCannotSeekSpec.cs | 31 ++++ .../Language/Flow/Response/StreamBodySpec.cs | 34 ++++ .../Flow/Response/StreamBodyWithNullSpec.cs | 21 +++ .../Language/Flow/Response/StringBodySpec.cs | 20 +++ .../Response/StringBodyWithEncodingSpec.cs | 21 +++ .../Flow/Response/StringBodyWithNullSpec.cs | 26 +++ .../Flow/Response/TransferRateSpec.cs | 37 ++++ 55 files changed, 1254 insertions(+), 60 deletions(-) create mode 100644 test/MockHttp.Json.Tests/Newtonsoft/Specs/Response/JsonBodySpec.cs create mode 100644 test/MockHttp.Json.Tests/Newtonsoft/Specs/Response/JsonBodyWithDateTimeOffset.cs create mode 100644 test/MockHttp.Json.Tests/Newtonsoft/Specs/Response/JsonBodyWithEncodingSpec.cs create mode 100644 test/MockHttp.Json.Tests/Newtonsoft/Specs/Response/JsonBodyWithNullSpec.cs create mode 100644 test/MockHttp.Json.Tests/Newtonsoft/Specs/Response/JsonBodyWithStringSpec.cs create mode 100644 test/MockHttp.Json.Tests/Specs/Response/JsonBodySpec.cs create mode 100644 test/MockHttp.Json.Tests/Specs/Response/JsonBodyWithDateTimeOffset.cs create mode 100644 test/MockHttp.Json.Tests/Specs/Response/JsonBodyWithEncodingSpec.cs create mode 100644 test/MockHttp.Json.Tests/Specs/Response/JsonBodyWithNullSpec.cs create mode 100644 test/MockHttp.Json.Tests/Specs/Response/JsonBodyWithStringSpec.cs create mode 100644 test/MockHttp.Json.Tests/SystemTextJson/Specs/Response/JsonBodySpec.cs create mode 100644 test/MockHttp.Json.Tests/SystemTextJson/Specs/Response/JsonBodyWithDateTimeOffset.cs create mode 100644 test/MockHttp.Json.Tests/SystemTextJson/Specs/Response/JsonBodyWithEncodingSpec.cs create mode 100644 test/MockHttp.Json.Tests/SystemTextJson/Specs/Response/JsonBodyWithNullSpec.cs create mode 100644 test/MockHttp.Json.Tests/SystemTextJson/Specs/Response/JsonBodyWithStringSpec.cs create mode 100644 test/MockHttp.Testing/Specs/GuardedResponseSpec.cs create mode 100644 test/MockHttp.Testing/Specs/ResponseSpec.cs create mode 100644 test/MockHttp.Tests/Language/Flow/Response/ByteBodySpec.cs create mode 100644 test/MockHttp.Tests/Language/Flow/Response/ClientTimeoutSpec.cs create mode 100644 test/MockHttp.Tests/Language/Flow/Response/ContentTypeWithEncodingSpec.cs create mode 100644 test/MockHttp.Tests/Language/Flow/Response/ContentTypeWithNullSpec.cs create mode 100644 test/MockHttp.Tests/Language/Flow/Response/ContentTypeWithWebEncodingSpec.cs create mode 100644 test/MockHttp.Tests/Language/Flow/Response/ContentTypeWithoutEncodingSpec.cs create mode 100644 test/MockHttp.Tests/Language/Flow/Response/FuncStreamBodyCannotReadSpec.cs create mode 100644 test/MockHttp.Tests/Language/Flow/Response/FuncStreamBodySpec.cs create mode 100644 test/MockHttp.Tests/Language/Flow/Response/FuncStreamBodyWithNullSpec.cs create mode 100644 test/MockHttp.Tests/Language/Flow/Response/HeaderSpec.cs create mode 100644 test/MockHttp.Tests/Language/Flow/Response/HeaderWithoutNameSpec.cs create mode 100644 test/MockHttp.Tests/Language/Flow/Response/HeaderWithoutValueSpec.cs create mode 100644 test/MockHttp.Tests/Language/Flow/Response/LatencySpec.cs create mode 100644 test/MockHttp.Tests/Language/Flow/Response/PartialByteBodySpec.cs create mode 100644 test/MockHttp.Tests/Language/Flow/Response/PartialByteBodyWithCountOutOfRangeSpec.cs create mode 100644 test/MockHttp.Tests/Language/Flow/Response/PartialByteBodyWithOffsetOutOfRangeSpec.cs create mode 100644 test/MockHttp.Tests/Language/Flow/Response/ServerTimeoutSpec.cs create mode 100644 test/MockHttp.Tests/Language/Flow/Response/StatusCodeOutOfRangeSpec.cs create mode 100644 test/MockHttp.Tests/Language/Flow/Response/StatusCodeSpec.cs create mode 100644 test/MockHttp.Tests/Language/Flow/Response/StatusCodeWithStringBodySpec.cs create mode 100644 test/MockHttp.Tests/Language/Flow/Response/StreamBodyCannotReadSpec.cs create mode 100644 test/MockHttp.Tests/Language/Flow/Response/StreamBodyCannotSeekSpec.cs create mode 100644 test/MockHttp.Tests/Language/Flow/Response/StreamBodySpec.cs create mode 100644 test/MockHttp.Tests/Language/Flow/Response/StreamBodyWithNullSpec.cs create mode 100644 test/MockHttp.Tests/Language/Flow/Response/StringBodySpec.cs create mode 100644 test/MockHttp.Tests/Language/Flow/Response/StringBodyWithEncodingSpec.cs create mode 100644 test/MockHttp.Tests/Language/Flow/Response/StringBodyWithNullSpec.cs create mode 100644 test/MockHttp.Tests/Language/Flow/Response/TransferRateSpec.cs diff --git a/src/MockHttp.Json/Newtonsoft/ResponseBuilderExtensions.cs b/src/MockHttp.Json/Newtonsoft/ResponseBuilderExtensions.cs index 2d81eabd..004c0161 100644 --- a/src/MockHttp.Json/Newtonsoft/ResponseBuilderExtensions.cs +++ b/src/MockHttp.Json/Newtonsoft/ResponseBuilderExtensions.cs @@ -28,7 +28,7 @@ public static IWithContentResult JsonBody JsonSerializerSettings? serializerSettings = null ) { - return builder.JsonBody(jsonContent, encoding, new NewtonsoftAdapter(serializerSettings)); + return builder.JsonBody(_ => jsonContent, encoding, serializerSettings); } /// @@ -40,6 +40,7 @@ public static IWithContentResult JsonBody /// The optional JSON serializer settings. When null uses the default serializer settings. /// The builder to continue chaining additional behaviors. /// Thrown when or is . + // ReSharper disable once MemberCanBePrivate.Global public static IWithContentResult JsonBody ( this IWithContent builder, diff --git a/src/MockHttp.Json/SystemTextJson/ResponseBuilderExtensions.cs b/src/MockHttp.Json/SystemTextJson/ResponseBuilderExtensions.cs index 4e073598..3dd9a5ff 100644 --- a/src/MockHttp.Json/SystemTextJson/ResponseBuilderExtensions.cs +++ b/src/MockHttp.Json/SystemTextJson/ResponseBuilderExtensions.cs @@ -28,7 +28,7 @@ public static IWithContentResult JsonBody JsonSerializerOptions? serializerOptions = null ) { - return builder.JsonBody(jsonContent, encoding, new SystemTextJsonAdapter(serializerOptions)); + return builder.JsonBody(_ => jsonContent, encoding, serializerOptions); } /// @@ -40,6 +40,7 @@ public static IWithContentResult JsonBody /// The optional JSON serializer options. When null uses the default serializer settings. /// The builder to continue chaining additional behaviors. /// Thrown when or is . + // ReSharper disable once MemberCanBePrivate.Global public static IWithContentResult JsonBody ( this IWithContent builder, diff --git a/src/MockHttp/Extensions/IRespondsExtensions.cs b/src/MockHttp/Extensions/IRespondsExtensions.cs index 997bbe70..747a9a71 100644 --- a/src/MockHttp/Extensions/IRespondsExtensions.cs +++ b/src/MockHttp/Extensions/IRespondsExtensions.cs @@ -398,7 +398,7 @@ public static TResult TimesOutAfter(this IResponds responds, T throw new ArgumentNullException(nameof(responds)); } - return responds.Respond(with => with.TimesOutAfter(timeoutAfter)); + return responds.Respond(with => with.ClientTimeout(timeoutAfter)); } /// diff --git a/src/MockHttp/Extensions/ListExtensions.cs b/src/MockHttp/Extensions/ListExtensions.cs index 33dad40d..8c3fd8ff 100644 --- a/src/MockHttp/Extensions/ListExtensions.cs +++ b/src/MockHttp/Extensions/ListExtensions.cs @@ -33,6 +33,13 @@ bool IsExact(Type thisType) list.RemoveAt(existing[i]); } - list.Add(instance); + if (existing.Length > 0) + { + list.Insert(existing[0], instance); + } + else + { + list.Add(instance); + } } } diff --git a/src/MockHttp/Extensions/ResponseBuilderExtensions.cs b/src/MockHttp/Extensions/ResponseBuilderExtensions.cs index 058e039f..8ec183f0 100644 --- a/src/MockHttp/Extensions/ResponseBuilderExtensions.cs +++ b/src/MockHttp/Extensions/ResponseBuilderExtensions.cs @@ -1,4 +1,6 @@ #nullable enable +using System.ComponentModel; +using System.Globalization; using System.Net; using System.Net.Http.Headers; using System.Text; @@ -24,6 +26,11 @@ public static class ResponseBuilderExtensions /// Thrown when is less than 100. public static IWithStatusCodeResult StatusCode(this IWithStatusCode builder, int statusCode) { + if (builder is null) + { + throw new ArgumentNullException(nameof(builder)); + } + return builder.StatusCode((HttpStatusCode)statusCode); } @@ -37,6 +44,11 @@ public static IWithStatusCodeResult StatusCode(this IWithStatusCode builder, int /// Thrown when or is . public static IWithContentResult Body(this IWithContent builder, string content, Encoding? encoding = null) { + if (builder is null) + { + throw new ArgumentNullException(nameof(builder)); + } + if (content is null) { throw new ArgumentNullException(nameof(content)); @@ -69,6 +81,11 @@ public static IWithContentResult Body(this IWithContent builder, byte[] content) /// Thrown when or exceeds beyond end of array. public static IWithContentResult Body(this IWithContent builder, byte[] content, int offset, int count) { + if (builder is null) + { + throw new ArgumentNullException(nameof(builder)); + } + if (offset < 0 || offset > content.Length) { throw new ArgumentOutOfRangeException(nameof(offset)); @@ -83,11 +100,13 @@ public static IWithContentResult Body(this IWithContent builder, byte[] content, } /// - /// Sets the binary content for the response. - /// It is recommend to use the overload that accepts Func<Stream> with very large streams due to internal in-memory buffering. + /// Sets the stream content for the response. + /// This overload is not thread safe. Do not use when requests are (expected to be) served in parallel. + /// This overload should not be used with large (> ~1 megabyte) non-seekable streams, due to internal buffering. + /// If the above cases are true, it is recommend to use the overload that accepts Func<Stream>. /// /// The builder. - /// The binary content. + /// The stream content. /// The builder to continue chaining additional behaviors. /// Thrown when or is . /// Thrown when the stream does not support reading. @@ -103,13 +122,26 @@ public static IWithContentResult Body(this IWithContent builder, Stream content) throw new ArgumentException("Cannot read from stream.", nameof(content)); } - // We must copy byte buffer because streams are not thread safe nor are reset to offset 0 after first use. + if (content.CanSeek) + { + // Stream is reusable, delegate to Func. + // Note: this is not thread safe! + long startPos = content.Position; + return builder.Body(() => + { + content.Position = startPos; + return content; + }); + } + + // Because stream is not seekable, we must serve from byte buffer as the stream cannot be + // rewound and served more than once. + // This could be an issue with very big streams. + // Should we throw if greater than a certain size and force use of Func instead? byte[] buffer; using (var ms = new MemoryStream()) { content.CopyTo(ms); - // This could be an issue with very big streams. - // Should we throw if greater than a certain size and force use of Func instead? buffer = ms.ToArray(); } @@ -117,25 +149,30 @@ public static IWithContentResult Body(this IWithContent builder, Stream content) } /// - /// Sets the binary content for the response using a factory returning a new on each invocation. + /// Sets the stream content for the response using a factory returning a new on each invocation. /// /// The builder. - /// The factory returning a new on each invocation containing the binary content. + /// The factory returning a new on each invocation containing the binary content. /// The builder to continue chaining additional behaviors. - /// Thrown when or is . - public static IWithContentResult Body(this IWithContent builder, Func streamFactory) + /// Thrown when or is . + public static IWithContentResult Body(this IWithContent builder, Func contentFactory) { - if (streamFactory is null) + if (builder is null) + { + throw new ArgumentNullException(nameof(builder)); + } + + if (contentFactory is null) { - throw new ArgumentNullException(nameof(streamFactory)); + throw new ArgumentNullException(nameof(contentFactory)); } return builder.Body(_ => { - Stream stream = streamFactory(); + Stream stream = contentFactory(); if (!stream.CanRead) { - throw new ArgumentException("Cannot read from stream.", nameof(stream)); + throw new InvalidOperationException("Cannot read from stream."); } return Task.FromResult(new StreamContent(stream)); @@ -143,27 +180,33 @@ public static IWithContentResult Body(this IWithContent builder, Func st } /// - /// Sets the content type for the response. Will be ignored if no content is set. + /// Sets the media type for the response. Will be ignored if no content is set. /// /// The builder. - /// The content type. + /// The media type. /// The optional encoding. /// The builder to continue chaining additional behaviors. - /// Thrown when or is . - public static IWithHeadersResult ContentType(this IWithContentType builder, string contentType, Encoding? encoding = null) + /// Thrown when or is . + /// Throw if the is invalid. + public static IWithHeadersResult ContentType(this IWithContentType builder, string mediaType, Encoding? encoding = null) { - if (contentType is null) + if (builder is null) + { + throw new ArgumentNullException(nameof(builder)); + } + + if (mediaType is null) { - throw new ArgumentNullException(nameof(contentType)); + throw new ArgumentNullException(nameof(mediaType)); } - var mediaType = MediaTypeHeaderValue.Parse(contentType); + var mediaTypeHeaderValue = MediaTypeHeaderValue.Parse(mediaType); if (encoding is not null) { - mediaType.CharSet = encoding.WebName; + mediaTypeHeaderValue.CharSet = encoding.WebName; } - return builder.ContentType(mediaType); + return builder.ContentType(mediaTypeHeaderValue); } /// @@ -171,61 +214,62 @@ public static IWithHeadersResult ContentType(this IWithContentType builder, stri /// /// The builder. /// The header name. - /// The header value. + /// The header value or values. /// The builder to continue chaining additional behaviors. /// Thrown when or is . - public static IWithHeadersResult Header(this IWithHeaders builder, string name, string value) + public static IWithHeadersResult Header(this IWithHeaders builder, string name, params T?[] value) { - return builder.Header(name, new[] { value }); - } + if (builder is null) + { + throw new ArgumentNullException(nameof(builder)); + } - /// - /// Adds HTTP header with values. - /// - /// The builder. - /// The header name. - /// The header values. - /// The builder to continue chaining additional behaviors. - /// Thrown when or is . - public static IWithHeadersResult Header(this IWithHeaders builder, string name, IEnumerable values) - { if (name is null) { throw new ArgumentNullException(nameof(name)); } - return builder.Headers(new HttpHeadersCollection { { name, values } }); + // ReSharper disable once ConditionalAccessQualifierIsNonNullableAccordingToAPIContract + return builder.Headers(new HttpHeadersCollection { { name, value?.Select(ConvertToString).ToArray() ?? Array.Empty() } }); } /// - /// Specifies to throw a simulating a HTTP client request timeout. - /// Note: the response is short-circuited when running outside of the HTTP mock server. To simulate server timeouts, use .StatusCode(HttpStatusCode.RequestTimeout) instead. + /// Specifies to throw a after a specified amount of time, simulating a HTTP client request timeout. + /// Note: the response is short-circuited when running outside of the HTTP mock server. To simulate server timeouts, use .StatusCode(HttpStatusCode.RequestTimeout) or instead. /// /// The builder. + /// The time after which the timeout occurs. If , the is thrown immediately. /// The builder to continue chaining additional behaviors. /// Thrown when is . - public static IResponseBuilder TimesOut(this IResponseBuilder builder) + public static IWithResponse ClientTimeout(this IResponseBuilder builder, TimeSpan? timeoutAfter = null) { - return builder.TimesOutAfter(TimeSpan.Zero); + if (builder is null) + { + throw new ArgumentNullException(nameof(builder)); + } + + builder.Behaviors.Replace(new TimeoutBehavior(timeoutAfter ?? TimeSpan.Zero)); + return builder; } /// - /// Specifies to throw a after a specified amount of time, simulating a HTTP client request timeout. - /// Note: the response is short-circuited when running outside of the HTTP mock server. To simulate server timeouts, use .StatusCode(HttpStatusCode.RequestTimeout) instead. + /// Specifies to timeout the request after a specified amount of time with a , simulating a HTTP request timeout. /// /// The builder. - /// The time after which the timeout occurs. + /// The time after which the timeout occurs. If , the timeout occurs immediately (effectively this would then be the same as using .StatusCode(HttpStatusCode.RequestTimeout). /// The builder to continue chaining additional behaviors. /// Thrown when is . - public static IResponseBuilder TimesOutAfter(this IResponseBuilder builder, TimeSpan timeoutAfter) + public static IWithStatusCodeResult ServerTimeout(this IResponseBuilder builder, TimeSpan? timeoutAfter = null) { if (builder is null) { throw new ArgumentNullException(nameof(builder)); } - builder.Behaviors.Replace(new TimeoutBehavior(timeoutAfter)); - return builder; + TimeSpan timeout = timeoutAfter ?? TimeSpan.Zero; + IWithStatusCodeResult statusCodeResult = builder.StatusCode(HttpStatusCode.RequestTimeout); + statusCodeResult.Latency(NetworkLatency.Between(timeout, timeout.Add(TimeSpan.FromMilliseconds(1)))); + return statusCodeResult; } /// @@ -257,5 +301,30 @@ public static IWithResponse Latency(this IWithResponse builder, Func(T? v) + { + switch (v) + { + case string str: + return str; + + case DateTime dt: + return dt.ToString("R", DateTimeFormatInfo.InvariantInfo); + + case DateTimeOffset dtOffset: + return dtOffset.ToString("R", DateTimeFormatInfo.InvariantInfo); + + case null: + return null; + + default: + { + TypeConverter converter = TypeDescriptor.GetConverter(typeof(T)); + return converter.ConvertToString(null!, CultureInfo.InvariantCulture, v); + } + } + } + } #nullable restore diff --git a/src/MockHttp/Language/Flow/Response/ResponseBuilder.cs b/src/MockHttp/Language/Flow/Response/ResponseBuilder.cs index 816b22c8..d1622014 100644 --- a/src/MockHttp/Language/Flow/Response/ResponseBuilder.cs +++ b/src/MockHttp/Language/Flow/Response/ResponseBuilder.cs @@ -55,33 +55,49 @@ await Behaviors /// public IWithStatusCodeResult StatusCode(HttpStatusCode statusCode) { - Behaviors.Add(new StatusCodeBehavior(statusCode)); + Behaviors.Replace(new StatusCodeBehavior(statusCode)); return this; } /// public IWithContentResult Body(Func> httpContentFactory) { - Behaviors.Add(new HttpContentBehavior(httpContentFactory)); + Behaviors.Replace(new HttpContentBehavior(httpContentFactory)); return this; } /// public IWithHeadersResult ContentType(MediaTypeHeaderValue mediaType) { - return Headers(new HttpHeadersCollection { { "Content-Type", new[] { mediaType.ToString() } } }); + if (mediaType is null) + { + throw new ArgumentNullException(nameof(mediaType)); + } + + return Headers(new HttpHeadersCollection { { "Content-Type", mediaType.ToString() } }); } /// public IWithHeadersResult Headers(IEnumerable>> headers) { + if (headers is null) + { + throw new ArgumentNullException(nameof(headers)); + } + + var headerList = headers.ToList(); + if (headerList.Count == 0) + { + throw new ArgumentException("At least one header must be specified.", nameof(headers)); + } + // If no content when adding headers, we force empty content so that content headers can still be set. if (!Behaviors.OfType().Any()) { Body(EmptyHttpContentFactory); } - Behaviors.Add(new HttpHeaderBehavior(headers)); + Behaviors.Add(new HttpHeaderBehavior(headerList)); return this; } } diff --git a/src/MockHttp/Language/Response/IWithHeaders.cs b/src/MockHttp/Language/Response/IWithHeaders.cs index 68177995..c1919f33 100644 --- a/src/MockHttp/Language/Response/IWithHeaders.cs +++ b/src/MockHttp/Language/Response/IWithHeaders.cs @@ -17,5 +17,6 @@ public interface IWithHeaders /// The headers. /// The builder to continue chaining additional behaviors. /// Thrown when is . + /// Thrown when is empty. IWithHeadersResult Headers(IEnumerable>> headers); } diff --git a/src/MockHttp/Responses/HttpHeaderBehavior.cs b/src/MockHttp/Responses/HttpHeaderBehavior.cs index 7f1cf5fd..e02bd438 100644 --- a/src/MockHttp/Responses/HttpHeaderBehavior.cs +++ b/src/MockHttp/Responses/HttpHeaderBehavior.cs @@ -31,6 +31,7 @@ internal sealed class HttpHeaderBehavior public HttpHeaderBehavior(IEnumerable>> headers) { + // ReSharper disable once ConditionalAccessQualifierIsNonNullableAccordingToAPIContract _headers = headers?.ToList() ?? throw new ArgumentNullException(nameof(headers)); } @@ -42,10 +43,18 @@ public Task HandleAsync(MockHttpRequestContext requestContext, HttpResponseMessa // Special case handling of headers which only allow single values. if (HeadersWithSingleValueOnly.Contains(header.Key)) { - responseMessage.Content?.Headers.Remove(header.Key); + if (responseMessage.Content?.Headers.TryGetValues(header.Key, out _) == true) + { + responseMessage.Content.Headers.Remove(header.Key); + } + if (responseMessage.Headers.TryGetValues(header.Key, out _)) + { + responseMessage.Headers.Remove(header.Key); + } } - responseMessage.Content?.Headers.Add(header.Key, header.Value); + responseMessage.Content?.Headers.TryAddWithoutValidation(header.Key, header.Value); + responseMessage.Headers.TryAddWithoutValidation(header.Key, header.Value); } return next(requestContext, responseMessage, cancellationToken); diff --git a/test/MockHttp.Json.Tests/Newtonsoft/Specs/Response/JsonBodySpec.cs b/test/MockHttp.Json.Tests/Newtonsoft/Specs/Response/JsonBodySpec.cs new file mode 100644 index 00000000..a25b73af --- /dev/null +++ b/test/MockHttp.Json.Tests/Newtonsoft/Specs/Response/JsonBodySpec.cs @@ -0,0 +1,9 @@ +namespace MockHttp.Json.Newtonsoft.Specs.Response; + +public class JsonBodySpec : Json.Specs.Response.JsonBodySpec +{ + protected override void Given(IResponseBuilder with) + { + with.JsonBody(new { firstName = "John", lastName = "Doe" }); + } +} diff --git a/test/MockHttp.Json.Tests/Newtonsoft/Specs/Response/JsonBodyWithDateTimeOffset.cs b/test/MockHttp.Json.Tests/Newtonsoft/Specs/Response/JsonBodyWithDateTimeOffset.cs new file mode 100644 index 00000000..910c571b --- /dev/null +++ b/test/MockHttp.Json.Tests/Newtonsoft/Specs/Response/JsonBodyWithDateTimeOffset.cs @@ -0,0 +1,9 @@ +namespace MockHttp.Json.Newtonsoft.Specs.Response; + +public class JsonBodyWithDateTimeOffset : Json.Specs.Response.JsonBodyWithDateTimeOffset +{ + protected override void Given(IResponseBuilder with) + { + with.JsonBody(new DateTimeOffset(2022, 5, 26, 10, 53, 34, 123, TimeSpan.FromHours(2))); + } +} diff --git a/test/MockHttp.Json.Tests/Newtonsoft/Specs/Response/JsonBodyWithEncodingSpec.cs b/test/MockHttp.Json.Tests/Newtonsoft/Specs/Response/JsonBodyWithEncodingSpec.cs new file mode 100644 index 00000000..55710c40 --- /dev/null +++ b/test/MockHttp.Json.Tests/Newtonsoft/Specs/Response/JsonBodyWithEncodingSpec.cs @@ -0,0 +1,9 @@ +namespace MockHttp.Json.Newtonsoft.Specs.Response; + +public class JsonBodyWithEncodingSpec : Json.Specs.Response.JsonBodyWithEncodingSpec +{ + protected override void Given(IResponseBuilder with) + { + with.JsonBody(new { firstName = "John", lastName = "Doe" }, Encoding); + } +} diff --git a/test/MockHttp.Json.Tests/Newtonsoft/Specs/Response/JsonBodyWithNullSpec.cs b/test/MockHttp.Json.Tests/Newtonsoft/Specs/Response/JsonBodyWithNullSpec.cs new file mode 100644 index 00000000..aae4d4b7 --- /dev/null +++ b/test/MockHttp.Json.Tests/Newtonsoft/Specs/Response/JsonBodyWithNullSpec.cs @@ -0,0 +1,9 @@ +namespace MockHttp.Json.Newtonsoft.Specs.Response; + +public class JsonBodyWithNullSpec : Json.Specs.Response.JsonBodyWithNullSpec +{ + protected override void Given(IResponseBuilder with) + { + with.JsonBody((object?)null); + } +} diff --git a/test/MockHttp.Json.Tests/Newtonsoft/Specs/Response/JsonBodyWithStringSpec.cs b/test/MockHttp.Json.Tests/Newtonsoft/Specs/Response/JsonBodyWithStringSpec.cs new file mode 100644 index 00000000..99c2ad04 --- /dev/null +++ b/test/MockHttp.Json.Tests/Newtonsoft/Specs/Response/JsonBodyWithStringSpec.cs @@ -0,0 +1,9 @@ +namespace MockHttp.Json.Newtonsoft.Specs.Response; + +public class JsonBodyWithStringSpec : Json.Specs.Response.JsonBodyWithStringSpec +{ + protected override void Given(IResponseBuilder with) + { + with.JsonBody("some text"); + } +} diff --git a/test/MockHttp.Json.Tests/Specs/Response/JsonBodySpec.cs b/test/MockHttp.Json.Tests/Specs/Response/JsonBodySpec.cs new file mode 100644 index 00000000..d820f6d2 --- /dev/null +++ b/test/MockHttp.Json.Tests/Specs/Response/JsonBodySpec.cs @@ -0,0 +1,21 @@ +using FluentAssertions; +using MockHttp.FluentAssertions; +using MockHttp.Specs; + +namespace MockHttp.Json.Specs.Response; + +public class JsonBodySpec : ResponseSpec +{ + protected override void Given(IResponseBuilder with) + { + with.JsonBody(new { firstName = "John", lastName = "Doe" }); + } + + protected override async Task Should(HttpResponseMessage response) + { + await base.Should(response); + (await response.Should() + .HaveContentAsync("{\"firstName\":\"John\",\"lastName\":\"Doe\"}")) + .And.HaveContentType("application/json; charset=utf-8"); + } +} diff --git a/test/MockHttp.Json.Tests/Specs/Response/JsonBodyWithDateTimeOffset.cs b/test/MockHttp.Json.Tests/Specs/Response/JsonBodyWithDateTimeOffset.cs new file mode 100644 index 00000000..bc18c7ef --- /dev/null +++ b/test/MockHttp.Json.Tests/Specs/Response/JsonBodyWithDateTimeOffset.cs @@ -0,0 +1,21 @@ +using FluentAssertions; +using MockHttp.FluentAssertions; +using MockHttp.Specs; + +namespace MockHttp.Json.Specs.Response; + +public class JsonBodyWithDateTimeOffset : ResponseSpec +{ + protected override void Given(IResponseBuilder with) + { + with.JsonBody(new DateTimeOffset(2022, 5, 26, 10, 53, 34, 123, TimeSpan.FromHours(2))); + } + + protected override async Task Should(HttpResponseMessage response) + { + await base.Should(response); + (await response.Should() + .HaveContentAsync("\"2022-05-26T10:53:34.123+02:00\"")) + .And.HaveContentType("application/json; charset=utf-8"); + } +} diff --git a/test/MockHttp.Json.Tests/Specs/Response/JsonBodyWithEncodingSpec.cs b/test/MockHttp.Json.Tests/Specs/Response/JsonBodyWithEncodingSpec.cs new file mode 100644 index 00000000..5a036c19 --- /dev/null +++ b/test/MockHttp.Json.Tests/Specs/Response/JsonBodyWithEncodingSpec.cs @@ -0,0 +1,24 @@ +using System.Text; +using FluentAssertions; +using MockHttp.FluentAssertions; +using MockHttp.Specs; + +namespace MockHttp.Json.Specs.Response; + +public class JsonBodyWithEncodingSpec : ResponseSpec +{ + protected readonly Encoding Encoding = Encoding.Unicode; + + protected override void Given(IResponseBuilder with) + { + with.JsonBody(new { firstName = "John", lastName = "Doe" }, Encoding); + } + + protected override async Task Should(HttpResponseMessage response) + { + await base.Should(response); + (await response.Should() + .HaveContentAsync("{\"firstName\":\"John\",\"lastName\":\"Doe\"}", Encoding)) + .And.HaveContentType("application/json; charset=utf-16"); + } +} diff --git a/test/MockHttp.Json.Tests/Specs/Response/JsonBodyWithNullSpec.cs b/test/MockHttp.Json.Tests/Specs/Response/JsonBodyWithNullSpec.cs new file mode 100644 index 00000000..6b171987 --- /dev/null +++ b/test/MockHttp.Json.Tests/Specs/Response/JsonBodyWithNullSpec.cs @@ -0,0 +1,21 @@ +using FluentAssertions; +using MockHttp.FluentAssertions; +using MockHttp.Specs; + +namespace MockHttp.Json.Specs.Response; + +public class JsonBodyWithNullSpec : ResponseSpec +{ + protected override void Given(IResponseBuilder with) + { + with.JsonBody((object?)null); + } + + protected override async Task Should(HttpResponseMessage response) + { + await base.Should(response); + (await response.Should() + .HaveContentAsync("null")) + .And.HaveContentType("application/json; charset=utf-8"); + } +} diff --git a/test/MockHttp.Json.Tests/Specs/Response/JsonBodyWithStringSpec.cs b/test/MockHttp.Json.Tests/Specs/Response/JsonBodyWithStringSpec.cs new file mode 100644 index 00000000..ed7ebca8 --- /dev/null +++ b/test/MockHttp.Json.Tests/Specs/Response/JsonBodyWithStringSpec.cs @@ -0,0 +1,21 @@ +using FluentAssertions; +using MockHttp.FluentAssertions; +using MockHttp.Specs; + +namespace MockHttp.Json.Specs.Response; + +public class JsonBodyWithStringSpec : ResponseSpec +{ + protected override void Given(IResponseBuilder with) + { + with.JsonBody("some text"); + } + + protected override async Task Should(HttpResponseMessage response) + { + await base.Should(response); + (await response.Should() + .HaveContentAsync("\"some text\"")) + .And.HaveContentType("application/json; charset=utf-8"); + } +} diff --git a/test/MockHttp.Json.Tests/SystemTextJson/Specs/Response/JsonBodySpec.cs b/test/MockHttp.Json.Tests/SystemTextJson/Specs/Response/JsonBodySpec.cs new file mode 100644 index 00000000..218f4700 --- /dev/null +++ b/test/MockHttp.Json.Tests/SystemTextJson/Specs/Response/JsonBodySpec.cs @@ -0,0 +1,9 @@ +namespace MockHttp.Json.SystemTextJson.Specs.Response; + +public class JsonBodySpec : Json.Specs.Response.JsonBodySpec +{ + protected override void Given(IResponseBuilder with) + { + with.JsonBody(new { firstName = "John", lastName = "Doe" }); + } +} diff --git a/test/MockHttp.Json.Tests/SystemTextJson/Specs/Response/JsonBodyWithDateTimeOffset.cs b/test/MockHttp.Json.Tests/SystemTextJson/Specs/Response/JsonBodyWithDateTimeOffset.cs new file mode 100644 index 00000000..e26b7c35 --- /dev/null +++ b/test/MockHttp.Json.Tests/SystemTextJson/Specs/Response/JsonBodyWithDateTimeOffset.cs @@ -0,0 +1,9 @@ +namespace MockHttp.Json.SystemTextJson.Specs.Response; + +public class JsonBodyWithDateTimeOffset : Json.Specs.Response.JsonBodyWithDateTimeOffset +{ + protected override void Given(IResponseBuilder with) + { + with.JsonBody(new DateTimeOffset(2022, 5, 26, 10, 53, 34, 123, TimeSpan.FromHours(2))); + } +} diff --git a/test/MockHttp.Json.Tests/SystemTextJson/Specs/Response/JsonBodyWithEncodingSpec.cs b/test/MockHttp.Json.Tests/SystemTextJson/Specs/Response/JsonBodyWithEncodingSpec.cs new file mode 100644 index 00000000..203b6228 --- /dev/null +++ b/test/MockHttp.Json.Tests/SystemTextJson/Specs/Response/JsonBodyWithEncodingSpec.cs @@ -0,0 +1,9 @@ +namespace MockHttp.Json.SystemTextJson.Specs.Response; + +public class JsonBodyWithEncodingSpec : Json.Specs.Response.JsonBodyWithEncodingSpec +{ + protected override void Given(IResponseBuilder with) + { + with.JsonBody(new { firstName = "John", lastName = "Doe" }, Encoding); + } +} diff --git a/test/MockHttp.Json.Tests/SystemTextJson/Specs/Response/JsonBodyWithNullSpec.cs b/test/MockHttp.Json.Tests/SystemTextJson/Specs/Response/JsonBodyWithNullSpec.cs new file mode 100644 index 00000000..2912dbfd --- /dev/null +++ b/test/MockHttp.Json.Tests/SystemTextJson/Specs/Response/JsonBodyWithNullSpec.cs @@ -0,0 +1,9 @@ +namespace MockHttp.Json.SystemTextJson.Specs.Response; + +public class JsonBodyWithNullSpec : Json.Specs.Response.JsonBodyWithNullSpec +{ + protected override void Given(IResponseBuilder with) + { + with.JsonBody((object?)null); + } +} diff --git a/test/MockHttp.Json.Tests/SystemTextJson/Specs/Response/JsonBodyWithStringSpec.cs b/test/MockHttp.Json.Tests/SystemTextJson/Specs/Response/JsonBodyWithStringSpec.cs new file mode 100644 index 00000000..57f4c6bd --- /dev/null +++ b/test/MockHttp.Json.Tests/SystemTextJson/Specs/Response/JsonBodyWithStringSpec.cs @@ -0,0 +1,9 @@ +namespace MockHttp.Json.SystemTextJson.Specs.Response; + +public class JsonBodyWithStringSpec : Json.Specs.Response.JsonBodyWithStringSpec +{ + protected override void Given(IResponseBuilder with) + { + with.JsonBody("some text"); + } +} diff --git a/test/MockHttp.Testing/FluentAssertions/HttpResponseMessageAssertionsExtensions.cs b/test/MockHttp.Testing/FluentAssertions/HttpResponseMessageAssertionsExtensions.cs index 3c9eebdd..c6e682bb 100644 --- a/test/MockHttp.Testing/FluentAssertions/HttpResponseMessageAssertionsExtensions.cs +++ b/test/MockHttp.Testing/FluentAssertions/HttpResponseMessageAssertionsExtensions.cs @@ -62,7 +62,33 @@ public static Task> HaveContentAsyn string because = "", params object[] becauseArgs) { - return should.HaveContentAsync(new ByteArrayContent(Encoding.UTF8.GetBytes(expectedContent)), because, becauseArgs); + return should.HaveContentAsync(expectedContent, Encoding.UTF8, because, becauseArgs); + } + + public static Task> HaveContentAsync + ( + this HttpResponseMessageAssertions should, + string expectedContent, + Encoding encoding, + string because = "", + params object[] becauseArgs) + { + if (encoding is null) + { + throw new ArgumentNullException(nameof(encoding)); + } + + return should.HaveContentAsync(new ByteArrayContent(encoding.GetBytes(expectedContent)), because, becauseArgs); + } + + public static Task> HaveContentAsync + ( + this HttpResponseMessageAssertions should, + byte[] expectedContent, + string because = "", + params object[] becauseArgs) + { + return should.HaveContentAsync(new ByteArrayContent(expectedContent), because, becauseArgs); } public static async Task> HaveContentAsync @@ -132,12 +158,14 @@ public static AndConstraint HaveHeader var expectedHeader = new KeyValuePair>(key, values); var equalityComparer = new HttpHeaderEqualityComparer(); + static bool SafeContains(HttpHeaders headers, string s) => headers?.TryGetValues(s, out _) ?? false; + var assertionScope = (IAssertionScope)Execute.Assertion.BecauseOf(because, becauseArgs).UsingLineBreaks; assertionScope .ForCondition(subject is not null) .FailWith("The subject is null.") .Then - .ForCondition(subject!.Headers.Contains(key) || (subject.Content?.Headers.Contains(key) ?? false)) + .ForCondition(SafeContains(subject.Headers, key) || SafeContains(subject.Content?.Headers, key)) .FailWith("Expected response to have header {0}{reason}, but found none.", key) .Then .ForCondition( diff --git a/test/MockHttp.Testing/Specs/GuardedResponseSpec.cs b/test/MockHttp.Testing/Specs/GuardedResponseSpec.cs new file mode 100644 index 00000000..f9049374 --- /dev/null +++ b/test/MockHttp.Testing/Specs/GuardedResponseSpec.cs @@ -0,0 +1,19 @@ +#nullable enable +namespace MockHttp.Specs; + +public abstract class GuardedResponseSpec : ResponseSpec +{ + protected override async Task When(HttpClient httpClient) + { + await ShouldThrow(() => base.When(httpClient)); + return null!; + } + + protected sealed override Task Should(HttpResponseMessage response) + { + return Task.CompletedTask; + } + + protected abstract Task ShouldThrow(Func act); +} +#nullable restore diff --git a/test/MockHttp.Testing/Specs/ResponseSpec.cs b/test/MockHttp.Testing/Specs/ResponseSpec.cs new file mode 100644 index 00000000..eeadda32 --- /dev/null +++ b/test/MockHttp.Testing/Specs/ResponseSpec.cs @@ -0,0 +1,64 @@ +#nullable enable +using System.Net; +using FluentAssertions; +using MockHttp.Language; +using Xunit; + +namespace MockHttp.Specs; + +public abstract class ResponseSpec : IAsyncLifetime +{ + private readonly MockHttpHandler _mockHttp; + private readonly HttpClient? _httpClient; + private readonly IConfiguredRequest _configuredRequest; + + protected ResponseSpec() + { + _mockHttp = new MockHttpHandler(); + _httpClient = new HttpClient(_mockHttp) + { + BaseAddress = new Uri("http://0.0.0.0") + }; + _configuredRequest = _mockHttp.When(_ => { }); + } + + [Fact] + public async Task Act() + { + HttpResponseMessage response = await When(_httpClient!); + + await Should(response); + } + + protected abstract void Given(IResponseBuilder with); + + protected virtual Task When(HttpClient httpClient) + { + _configuredRequest.Respond(Given); + return Send(httpClient); + } + + protected virtual Task Send(HttpClient httpClient) + { + return httpClient.GetAsync(""); + } + + protected virtual Task Should(HttpResponseMessage response) + { + response.Should().HaveStatusCode(HttpStatusCode.OK); + return Task.CompletedTask; + } + + Task IAsyncLifetime.InitializeAsync() + { + return Task.CompletedTask; + } + + public virtual Task DisposeAsync() + { + _httpClient?.Dispose(); + _mockHttp.Dispose(); + return Task.CompletedTask; + } +} +#nullable restore diff --git a/test/MockHttp.Tests/Extensions/IRespondsExtensionsTests.cs b/test/MockHttp.Tests/Extensions/IRespondsExtensionsTests.cs index ed549555..683783df 100644 --- a/test/MockHttp.Tests/Extensions/IRespondsExtensionsTests.cs +++ b/test/MockHttp.Tests/Extensions/IRespondsExtensionsTests.cs @@ -473,6 +473,9 @@ public static IEnumerable TestCases() IRespondsExtensions.TimesOutAfter, responds, TimeSpan.FromMilliseconds(1)), + DelegateTestCase.Create( + IRespondsExtensions.Respond, + responds, (Action)(_ => { })) }; return testCases.SelectMany(tc => tc.GetNullArgumentTestCases()); diff --git a/test/MockHttp.Tests/Language/Flow/Response/ByteBodySpec.cs b/test/MockHttp.Tests/Language/Flow/Response/ByteBodySpec.cs new file mode 100644 index 00000000..4d9c6b96 --- /dev/null +++ b/test/MockHttp.Tests/Language/Flow/Response/ByteBodySpec.cs @@ -0,0 +1,22 @@ +#nullable enable +using FluentAssertions; +using MockHttp.FluentAssertions; +using MockHttp.Specs; + +namespace MockHttp.Language.Flow.Response; + +public class ByteBodySpec : ResponseSpec +{ + protected byte[] Content { get; set; } = { 9, 8, 7, 6, 5, 4, 3, 2, 1, 0, byte.MaxValue }; + + protected override void Given(IResponseBuilder with) + { + with.Body(Content); + } + + protected override Task Should(HttpResponseMessage response) + { + return response.Should().HaveContentAsync(Content); + } +} +#nullable restore diff --git a/test/MockHttp.Tests/Language/Flow/Response/ClientTimeoutSpec.cs b/test/MockHttp.Tests/Language/Flow/Response/ClientTimeoutSpec.cs new file mode 100644 index 00000000..cae3cc82 --- /dev/null +++ b/test/MockHttp.Tests/Language/Flow/Response/ClientTimeoutSpec.cs @@ -0,0 +1,26 @@ +#nullable enable +using System.Diagnostics; +using FluentAssertions; +using MockHttp.Specs; + +namespace MockHttp.Language.Flow.Response; + +public class ClientTimeoutSpec : GuardedResponseSpec +{ + private readonly Stopwatch _stopwatch = new(); + private readonly TimeSpan _timeoutAfter = TimeSpan.FromMilliseconds(500); + + protected override void Given(IResponseBuilder with) + { + with.ClientTimeout(_timeoutAfter); + _stopwatch.Start(); + } + + protected override async Task ShouldThrow(Func act) + { + await act.Should().ThrowExactlyAsync(); + _stopwatch.Stop(); + _stopwatch.Elapsed.Should().BeGreaterThanOrEqualTo(_timeoutAfter); + } +} +#nullable restore diff --git a/test/MockHttp.Tests/Language/Flow/Response/ContentTypeWithEncodingSpec.cs b/test/MockHttp.Tests/Language/Flow/Response/ContentTypeWithEncodingSpec.cs new file mode 100644 index 00000000..20327a97 --- /dev/null +++ b/test/MockHttp.Tests/Language/Flow/Response/ContentTypeWithEncodingSpec.cs @@ -0,0 +1,23 @@ +#nullable enable +using System.Text; +using FluentAssertions; +using MockHttp.FluentAssertions; +using MockHttp.Specs; + +namespace MockHttp.Language.Flow.Response; + +public class ContentTypeWithEncodingSpec : ResponseSpec +{ + protected override void Given(IResponseBuilder with) + { + with.Body("Text") + .ContentType("text/html", Encoding.Unicode); + } + + protected override Task Should(HttpResponseMessage response) + { + response.Should().HaveContentType("text/html; charset=utf-16"); + return Task.CompletedTask; + } +} +#nullable restore diff --git a/test/MockHttp.Tests/Language/Flow/Response/ContentTypeWithNullSpec.cs b/test/MockHttp.Tests/Language/Flow/Response/ContentTypeWithNullSpec.cs new file mode 100644 index 00000000..e82794ae --- /dev/null +++ b/test/MockHttp.Tests/Language/Flow/Response/ContentTypeWithNullSpec.cs @@ -0,0 +1,38 @@ +#nullable enable +using FluentAssertions; +using MockHttp.Specs; + +namespace MockHttp.Language.Flow.Response; + +public class ContentTypeWithNullSpec : GuardedResponseSpec +{ + protected override void Given(IResponseBuilder with) + { + with.Body("") + .ContentType(null); + } + + protected override async Task ShouldThrow(Func act) + { + await act.Should() + .ThrowExactlyAsync() + .WithParameterName("mediaType"); + } +} + +public class ContentTypeStringWithNullSpec : GuardedResponseSpec +{ + protected override void Given(IResponseBuilder with) + { + with.Body("") + .ContentType((string)null!); + } + + protected override async Task ShouldThrow(Func act) + { + await act.Should() + .ThrowExactlyAsync() + .WithParameterName("mediaType"); + } +} +#nullable restore diff --git a/test/MockHttp.Tests/Language/Flow/Response/ContentTypeWithWebEncodingSpec.cs b/test/MockHttp.Tests/Language/Flow/Response/ContentTypeWithWebEncodingSpec.cs new file mode 100644 index 00000000..f1a091a1 --- /dev/null +++ b/test/MockHttp.Tests/Language/Flow/Response/ContentTypeWithWebEncodingSpec.cs @@ -0,0 +1,22 @@ +#nullable enable +using FluentAssertions; +using MockHttp.FluentAssertions; +using MockHttp.Specs; + +namespace MockHttp.Language.Flow.Response; + +public class ContentTypeWithWebEncodingSpec : ResponseSpec +{ + protected override void Given(IResponseBuilder with) + { + with.Body("Text") + .ContentType("text/html; charset=utf-16"); + } + + protected override Task Should(HttpResponseMessage response) + { + response.Should().HaveContentType("text/html; charset=utf-16"); + return Task.CompletedTask; + } +} +#nullable restore diff --git a/test/MockHttp.Tests/Language/Flow/Response/ContentTypeWithoutEncodingSpec.cs b/test/MockHttp.Tests/Language/Flow/Response/ContentTypeWithoutEncodingSpec.cs new file mode 100644 index 00000000..6f2d9c75 --- /dev/null +++ b/test/MockHttp.Tests/Language/Flow/Response/ContentTypeWithoutEncodingSpec.cs @@ -0,0 +1,23 @@ +#nullable enable +using FluentAssertions; +using MockHttp.FluentAssertions; +using MockHttp.Specs; + +namespace MockHttp.Language.Flow.Response; + +public class ContentTypeWithoutEncodingSpec : ResponseSpec +{ + protected override void Given(IResponseBuilder with) + { + with.Body("Text") + .ContentType("text/html"); + } + + protected override Task Should(HttpResponseMessage response) + { + response.Content.Should().NotBeNull(); + response.Should().HaveContentType("text/html"); + return Task.CompletedTask; + } +} +#nullable restore diff --git a/test/MockHttp.Tests/Language/Flow/Response/FuncStreamBodyCannotReadSpec.cs b/test/MockHttp.Tests/Language/Flow/Response/FuncStreamBodyCannotReadSpec.cs new file mode 100644 index 00000000..996fd44a --- /dev/null +++ b/test/MockHttp.Tests/Language/Flow/Response/FuncStreamBodyCannotReadSpec.cs @@ -0,0 +1,30 @@ +#nullable enable +using FluentAssertions; +using MockHttp.Specs; +using Moq; + +namespace MockHttp.Language.Flow.Response; + +public class FuncStreamBodyCannotReadSpec : GuardedResponseSpec +{ + private readonly Mock _streamMock = new(); + + protected override void Given(IResponseBuilder with) + { + _streamMock + .Setup(m => m.CanRead) + .Returns(false) + .Verifiable(); + + with.Body(() => _streamMock.Object); + } + + protected override async Task ShouldThrow(Func act) + { + await act.Should() + .ThrowExactlyAsync() + .WithMessage("Cannot read from stream.*"); + _streamMock.Verify(); + } +} +#nullable restore diff --git a/test/MockHttp.Tests/Language/Flow/Response/FuncStreamBodySpec.cs b/test/MockHttp.Tests/Language/Flow/Response/FuncStreamBodySpec.cs new file mode 100644 index 00000000..354c0bc1 --- /dev/null +++ b/test/MockHttp.Tests/Language/Flow/Response/FuncStreamBodySpec.cs @@ -0,0 +1,19 @@ +#nullable enable +using FluentAssertions; +using MockHttp.FluentAssertions; + +namespace MockHttp.Language.Flow.Response; + +public class FuncStreamBodySpec : ByteBodySpec +{ + protected override void Given(IResponseBuilder with) + { + with.Body(() => new MemoryStream(Content)); + } + + protected override Task Should(HttpResponseMessage response) + { + return response.Should().HaveContentAsync(Content); + } +} +#nullable restore diff --git a/test/MockHttp.Tests/Language/Flow/Response/FuncStreamBodyWithNullSpec.cs b/test/MockHttp.Tests/Language/Flow/Response/FuncStreamBodyWithNullSpec.cs new file mode 100644 index 00000000..67e33a4a --- /dev/null +++ b/test/MockHttp.Tests/Language/Flow/Response/FuncStreamBodyWithNullSpec.cs @@ -0,0 +1,21 @@ +#nullable enable +using FluentAssertions; +using MockHttp.Specs; + +namespace MockHttp.Language.Flow.Response; + +public class FuncStreamBodyWithNullSpec : GuardedResponseSpec +{ + protected override void Given(IResponseBuilder with) + { + with.Body((Func)null!); + } + + protected override Task ShouldThrow(Func act) + { + return act.Should() + .ThrowExactlyAsync() + .WithParameterName("contentFactory"); + } +} +#nullable restore diff --git a/test/MockHttp.Tests/Language/Flow/Response/HeaderSpec.cs b/test/MockHttp.Tests/Language/Flow/Response/HeaderSpec.cs new file mode 100644 index 00000000..da713324 --- /dev/null +++ b/test/MockHttp.Tests/Language/Flow/Response/HeaderSpec.cs @@ -0,0 +1,48 @@ +#nullable enable +using System.Globalization; +using FluentAssertions; +using MockHttp.FluentAssertions; +using MockHttp.Specs; + +namespace MockHttp.Language.Flow.Response; + +public class HeaderSpec : ResponseSpec +{ + private readonly DateTimeOffset _utcNow; + private readonly DateTime _now; + + public HeaderSpec() + { + _utcNow = DateTimeOffset.UtcNow; + _now = DateTime.Now; + } + + protected override void Given(IResponseBuilder with) + { + with.Header("Vary", "Accept") + .Header("Date", _utcNow.AddYears(-1)) + .Header("Content-Length", 123) + .Header("Content-Language", "nl", "fr") + .Header("X-Date", _now.AddYears(-1)); + + with.Header("Vary", "Content-Encoding") + .Header("Date", _utcNow) + .Header("Content-Length", 456) + .Header("Content-Language", "de") + .Header("X-Date", _now) + .Header("X-Null", (object?)null); + } + + protected override Task Should(HttpResponseMessage response) + { + response.Should() + .HaveHeader("Vary", "Accept,Content-Encoding") + .And.HaveHeader("Date", _utcNow.ToString("R", DateTimeFormatInfo.InvariantInfo)) + .And.HaveHeader("Content-Length", "456") + .And.HaveHeader("Content-Language", "nl,fr,de") + .And.HaveHeader("X-Date", _now.AddYears(-1).ToString("R") + "," + _now.ToString("R")) + .And.HaveHeader("X-Null", string.Empty); + return Task.CompletedTask; + } +} +#nullable restore diff --git a/test/MockHttp.Tests/Language/Flow/Response/HeaderWithoutNameSpec.cs b/test/MockHttp.Tests/Language/Flow/Response/HeaderWithoutNameSpec.cs new file mode 100644 index 00000000..d6954e83 --- /dev/null +++ b/test/MockHttp.Tests/Language/Flow/Response/HeaderWithoutNameSpec.cs @@ -0,0 +1,21 @@ +#nullable enable +using FluentAssertions; +using MockHttp.Specs; + +namespace MockHttp.Language.Flow.Response; + +public class HeaderWithoutNameSpec : GuardedResponseSpec +{ + protected override void Given(IResponseBuilder with) + { + with.Header(null!, Array.Empty()); + } + + protected override Task ShouldThrow(Func act) + { + return act.Should() + .ThrowExactlyAsync() + .WithParameterName("name"); + } +} +#nullable restore diff --git a/test/MockHttp.Tests/Language/Flow/Response/HeaderWithoutValueSpec.cs b/test/MockHttp.Tests/Language/Flow/Response/HeaderWithoutValueSpec.cs new file mode 100644 index 00000000..d49bc60c --- /dev/null +++ b/test/MockHttp.Tests/Language/Flow/Response/HeaderWithoutValueSpec.cs @@ -0,0 +1,22 @@ +#nullable enable +using FluentAssertions; +using MockHttp.Specs; + +namespace MockHttp.Language.Flow.Response; + +public class HeaderWithoutValueSpec : GuardedResponseSpec +{ + protected override void Given(IResponseBuilder with) + { + with.Header("X-Header", (string?[])null!); + } + + protected override Task ShouldThrow(Func act) + { + return act.Should() + .ThrowExactlyAsync() + .WithParameterName("headers") + .WithMessage("At least one header must be specified.*"); + } +} +#nullable restore diff --git a/test/MockHttp.Tests/Language/Flow/Response/LatencySpec.cs b/test/MockHttp.Tests/Language/Flow/Response/LatencySpec.cs new file mode 100644 index 00000000..a9bff294 --- /dev/null +++ b/test/MockHttp.Tests/Language/Flow/Response/LatencySpec.cs @@ -0,0 +1,27 @@ +#nullable enable +using System.Diagnostics; +using System.Net; +using FluentAssertions; +using MockHttp.Specs; + +namespace MockHttp.Language.Flow.Response; + +public class LatencySpec : ResponseSpec +{ + private readonly Stopwatch _stopwatch = new(); + + protected override void Given(IResponseBuilder with) + { + with.Latency(NetworkLatency.TwoG); + _stopwatch.Start(); + } + + protected override Task Should(HttpResponseMessage response) + { + _stopwatch.Stop(); + _stopwatch.Elapsed.Should().BeGreaterThanOrEqualTo(TimeSpan.FromMilliseconds(300)); + response.Should().HaveStatusCode(HttpStatusCode.OK); + return Task.CompletedTask; + } +} +#nullable restore diff --git a/test/MockHttp.Tests/Language/Flow/Response/PartialByteBodySpec.cs b/test/MockHttp.Tests/Language/Flow/Response/PartialByteBodySpec.cs new file mode 100644 index 00000000..4764a786 --- /dev/null +++ b/test/MockHttp.Tests/Language/Flow/Response/PartialByteBodySpec.cs @@ -0,0 +1,19 @@ +#nullable enable +using FluentAssertions; +using MockHttp.FluentAssertions; + +namespace MockHttp.Language.Flow.Response; + +public class PartialByteBodySpec : ByteBodySpec +{ + protected override void Given(IResponseBuilder with) + { + with.Body(Content, 4, 4); + } + + protected override Task Should(HttpResponseMessage response) + { + return response.Should().HaveContentAsync(new byte[] { 5, 4, 3, 2 }); + } +} +#nullable restore diff --git a/test/MockHttp.Tests/Language/Flow/Response/PartialByteBodyWithCountOutOfRangeSpec.cs b/test/MockHttp.Tests/Language/Flow/Response/PartialByteBodyWithCountOutOfRangeSpec.cs new file mode 100644 index 00000000..bd0159c7 --- /dev/null +++ b/test/MockHttp.Tests/Language/Flow/Response/PartialByteBodyWithCountOutOfRangeSpec.cs @@ -0,0 +1,36 @@ +#nullable enable +using FluentAssertions; +using MockHttp.Specs; + +namespace MockHttp.Language.Flow.Response; + +public class PartialByteBodyWithCountOutOfRangeSpec : GuardedResponseSpec +{ + protected override void Given(IResponseBuilder with) + { + with.Body(Array.Empty(), 0, 10); + } + + protected override Task ShouldThrow(Func act) + { + return act.Should() + .ThrowExactlyAsync() + .WithParameterName("count"); + } +} + +public class PartialByteBodyWithCountLessThanZeroSpec : GuardedResponseSpec +{ + protected override void Given(IResponseBuilder with) + { + with.Body(Array.Empty(), 0, -1); + } + + protected override Task ShouldThrow(Func act) + { + return act.Should() + .ThrowExactlyAsync() + .WithParameterName("count"); + } +} +#nullable restore diff --git a/test/MockHttp.Tests/Language/Flow/Response/PartialByteBodyWithOffsetOutOfRangeSpec.cs b/test/MockHttp.Tests/Language/Flow/Response/PartialByteBodyWithOffsetOutOfRangeSpec.cs new file mode 100644 index 00000000..062ef9d3 --- /dev/null +++ b/test/MockHttp.Tests/Language/Flow/Response/PartialByteBodyWithOffsetOutOfRangeSpec.cs @@ -0,0 +1,36 @@ +#nullable enable +using FluentAssertions; +using MockHttp.Specs; + +namespace MockHttp.Language.Flow.Response; + +public class PartialByteBodyWithOffsetOutOfRangeSpec : GuardedResponseSpec +{ + protected override void Given(IResponseBuilder with) + { + with.Body(Array.Empty(), 10, 0); + } + + protected override Task ShouldThrow(Func act) + { + return act.Should() + .ThrowExactlyAsync() + .WithParameterName("offset"); + } +} + +public class PartialByteBodyWithOffsetLessThanZeroSpec : GuardedResponseSpec +{ + protected override void Given(IResponseBuilder with) + { + with.Body(Array.Empty(), -1, 0); + } + + protected override Task ShouldThrow(Func act) + { + return act.Should() + .ThrowExactlyAsync() + .WithParameterName("offset"); + } +} +#nullable restore diff --git a/test/MockHttp.Tests/Language/Flow/Response/ServerTimeoutSpec.cs b/test/MockHttp.Tests/Language/Flow/Response/ServerTimeoutSpec.cs new file mode 100644 index 00000000..952662c6 --- /dev/null +++ b/test/MockHttp.Tests/Language/Flow/Response/ServerTimeoutSpec.cs @@ -0,0 +1,28 @@ +#nullable enable +using System.Diagnostics; +using System.Net; +using FluentAssertions; +using MockHttp.Specs; + +namespace MockHttp.Language.Flow.Response; + +public class ServerTimeoutSpec : ResponseSpec +{ + private readonly Stopwatch _stopwatch = new(); + private readonly TimeSpan _timeoutAfter = TimeSpan.FromMilliseconds(500); + + protected override void Given(IResponseBuilder with) + { + with.ServerTimeout(_timeoutAfter); + _stopwatch.Start(); + } + + protected override Task Should(HttpResponseMessage response) + { + _stopwatch.Stop(); + _stopwatch.Elapsed.Should().BeGreaterThanOrEqualTo(_timeoutAfter); + response.Should().HaveStatusCode(HttpStatusCode.RequestTimeout); + return Task.CompletedTask; + } +} +#nullable restore diff --git a/test/MockHttp.Tests/Language/Flow/Response/StatusCodeOutOfRangeSpec.cs b/test/MockHttp.Tests/Language/Flow/Response/StatusCodeOutOfRangeSpec.cs new file mode 100644 index 00000000..345b9b93 --- /dev/null +++ b/test/MockHttp.Tests/Language/Flow/Response/StatusCodeOutOfRangeSpec.cs @@ -0,0 +1,37 @@ +#nullable enable +using System.Net; +using FluentAssertions; +using MockHttp.Specs; + +namespace MockHttp.Language.Flow.Response; + +public class StatusCodeOutOfRangeSpec : GuardedResponseSpec +{ + protected override void Given(IResponseBuilder with) + { + with.StatusCode((HttpStatusCode)99); + } + + protected override Task ShouldThrow(Func act) + { + return act.Should() + .ThrowExactlyAsync() + .WithParameterName("statusCode"); + } +} + +public class StatusCodeInt32OutOfRangeSpec : GuardedResponseSpec +{ + protected override void Given(IResponseBuilder with) + { + with.StatusCode(99); + } + + protected override Task ShouldThrow(Func act) + { + return act.Should() + .ThrowExactlyAsync() + .WithParameterName("statusCode"); + } +} +#nullable restore diff --git a/test/MockHttp.Tests/Language/Flow/Response/StatusCodeSpec.cs b/test/MockHttp.Tests/Language/Flow/Response/StatusCodeSpec.cs new file mode 100644 index 00000000..1a60629c --- /dev/null +++ b/test/MockHttp.Tests/Language/Flow/Response/StatusCodeSpec.cs @@ -0,0 +1,35 @@ +#nullable enable +using System.Net; +using FluentAssertions; +using MockHttp.Specs; + +namespace MockHttp.Language.Flow.Response; + +public class StatusCodeSpec : ResponseSpec +{ + protected override void Given(IResponseBuilder with) + { + with.StatusCode(HttpStatusCode.BadRequest); + } + + protected override Task Should(HttpResponseMessage response) + { + response.Should().HaveStatusCode(HttpStatusCode.BadRequest); + return Task.CompletedTask; + } +} + +public class StatusCodeInt32Spec : ResponseSpec +{ + protected override void Given(IResponseBuilder with) + { + with.StatusCode(400); + } + + protected override Task Should(HttpResponseMessage response) + { + response.Should().HaveStatusCode(HttpStatusCode.BadRequest); + return Task.CompletedTask; + } +} +#nullable restore diff --git a/test/MockHttp.Tests/Language/Flow/Response/StatusCodeWithStringBodySpec.cs b/test/MockHttp.Tests/Language/Flow/Response/StatusCodeWithStringBodySpec.cs new file mode 100644 index 00000000..9cba60d5 --- /dev/null +++ b/test/MockHttp.Tests/Language/Flow/Response/StatusCodeWithStringBodySpec.cs @@ -0,0 +1,24 @@ +#nullable enable +using System.Net; +using FluentAssertions; +using MockHttp.FluentAssertions; +using MockHttp.Specs; + +namespace MockHttp.Language.Flow.Response; + +public class StatusCodeWithStringBodySpec : ResponseSpec +{ + protected override void Given(IResponseBuilder with) + { + with.StatusCode(HttpStatusCode.InternalServerError) + .Body("my text"); + } + + protected override Task Should(HttpResponseMessage response) + { + return response.Should() + .HaveStatusCode(HttpStatusCode.InternalServerError) + .And.HaveContentAsync("my text"); + } +} +#nullable restore diff --git a/test/MockHttp.Tests/Language/Flow/Response/StreamBodyCannotReadSpec.cs b/test/MockHttp.Tests/Language/Flow/Response/StreamBodyCannotReadSpec.cs new file mode 100644 index 00000000..29f0eeaf --- /dev/null +++ b/test/MockHttp.Tests/Language/Flow/Response/StreamBodyCannotReadSpec.cs @@ -0,0 +1,31 @@ +#nullable enable +using FluentAssertions; +using MockHttp.Specs; +using Moq; + +namespace MockHttp.Language.Flow.Response; + +public class StreamBodyCannotReadSpec : GuardedResponseSpec +{ + private readonly Mock _streamMock = new(); + + protected override void Given(IResponseBuilder with) + { + _streamMock + .Setup(m => m.CanRead) + .Returns(false) + .Verifiable(); + + with.Body(_streamMock.Object); + } + + protected override async Task ShouldThrow(Func act) + { + await act.Should() + .ThrowExactlyAsync() + .WithParameterName("content") + .WithMessage("Cannot read from stream.*"); + _streamMock.Verify(); + } +} +#nullable restore diff --git a/test/MockHttp.Tests/Language/Flow/Response/StreamBodyCannotSeekSpec.cs b/test/MockHttp.Tests/Language/Flow/Response/StreamBodyCannotSeekSpec.cs new file mode 100644 index 00000000..0c86cc16 --- /dev/null +++ b/test/MockHttp.Tests/Language/Flow/Response/StreamBodyCannotSeekSpec.cs @@ -0,0 +1,31 @@ +#nullable enable +using Moq; + +namespace MockHttp.Language.Flow.Response; + +public class StreamBodyCannotSeekSpec : StreamBodySpec +{ + private Mock? _streamMock; + + protected override Stream CreateStream() + { + _streamMock = new Mock(Content) { CallBase = true }; + _streamMock + .Setup(s => s.CanSeek) + .Returns(false); + return _streamMock.Object; + } + + protected override Task Should(HttpResponseMessage response) + { + _streamMock?.Verify(); + return base.Should(response); + } + + public override Task DisposeAsync() + { + _streamMock?.Object.Dispose(); + return base.DisposeAsync(); + } +} +#nullable restore diff --git a/test/MockHttp.Tests/Language/Flow/Response/StreamBodySpec.cs b/test/MockHttp.Tests/Language/Flow/Response/StreamBodySpec.cs new file mode 100644 index 00000000..7a397fb2 --- /dev/null +++ b/test/MockHttp.Tests/Language/Flow/Response/StreamBodySpec.cs @@ -0,0 +1,34 @@ +#nullable enable +using FluentAssertions; +using MockHttp.FluentAssertions; + +namespace MockHttp.Language.Flow.Response; + +public class StreamBodySpec : ByteBodySpec +{ + private Stream? _stream; + + protected override void Given(IResponseBuilder with) + { + _stream?.Dispose(); + _stream = CreateStream(); + with.Body(_stream); + } + + protected virtual Stream CreateStream() + { + return new MemoryStream(Content); + } + + protected override Task Should(HttpResponseMessage response) + { + return response.Should().HaveContentAsync(Content); + } + + public override Task DisposeAsync() + { + _stream?.Dispose(); + return base.DisposeAsync(); + } +} +#nullable restore diff --git a/test/MockHttp.Tests/Language/Flow/Response/StreamBodyWithNullSpec.cs b/test/MockHttp.Tests/Language/Flow/Response/StreamBodyWithNullSpec.cs new file mode 100644 index 00000000..c43c1af1 --- /dev/null +++ b/test/MockHttp.Tests/Language/Flow/Response/StreamBodyWithNullSpec.cs @@ -0,0 +1,21 @@ +#nullable enable +using FluentAssertions; +using MockHttp.Specs; + +namespace MockHttp.Language.Flow.Response; + +public class StreamBodyWithNullSpec : GuardedResponseSpec +{ + protected override void Given(IResponseBuilder with) + { + with.Body((Stream)null!); + } + + protected override Task ShouldThrow(Func act) + { + return act.Should() + .ThrowExactlyAsync() + .WithParameterName("content"); + } +} +#nullable restore diff --git a/test/MockHttp.Tests/Language/Flow/Response/StringBodySpec.cs b/test/MockHttp.Tests/Language/Flow/Response/StringBodySpec.cs new file mode 100644 index 00000000..2ebc2913 --- /dev/null +++ b/test/MockHttp.Tests/Language/Flow/Response/StringBodySpec.cs @@ -0,0 +1,20 @@ +#nullable enable +using FluentAssertions; +using MockHttp.FluentAssertions; +using MockHttp.Specs; + +namespace MockHttp.Language.Flow.Response; + +public class StringBodySpec : ResponseSpec +{ + protected override void Given(IResponseBuilder with) + { + with.Body("my text"); + } + + protected override Task Should(HttpResponseMessage response) + { + return response.Should().HaveContentAsync("my text"); + } +} +#nullable restore diff --git a/test/MockHttp.Tests/Language/Flow/Response/StringBodyWithEncodingSpec.cs b/test/MockHttp.Tests/Language/Flow/Response/StringBodyWithEncodingSpec.cs new file mode 100644 index 00000000..3656ad3f --- /dev/null +++ b/test/MockHttp.Tests/Language/Flow/Response/StringBodyWithEncodingSpec.cs @@ -0,0 +1,21 @@ +#nullable enable +using System.Text; +using FluentAssertions; +using MockHttp.FluentAssertions; +using MockHttp.Specs; + +namespace MockHttp.Language.Flow.Response; + +public class StringBodyWithEncodingSpec : ResponseSpec +{ + protected override void Given(IResponseBuilder with) + { + with.Body("my text", Encoding.Unicode); + } + + protected override Task Should(HttpResponseMessage response) + { + return response.Should().HaveContentAsync("my text", Encoding.Unicode); + } +} +#nullable restore diff --git a/test/MockHttp.Tests/Language/Flow/Response/StringBodyWithNullSpec.cs b/test/MockHttp.Tests/Language/Flow/Response/StringBodyWithNullSpec.cs new file mode 100644 index 00000000..c867bdf2 --- /dev/null +++ b/test/MockHttp.Tests/Language/Flow/Response/StringBodyWithNullSpec.cs @@ -0,0 +1,26 @@ +#nullable enable +using FluentAssertions; + +namespace MockHttp.Language.Flow.Response; + +public class StringBodyWithNullSpec : StringBodySpec +{ + protected override void Given(IResponseBuilder with) + { + string? content = null; + + // Act + Action act = () => with.Body(content!); + + // Assert + act.Should() + .ThrowExactly() + .WithParameterName(nameof(content)); + } + + protected override Task Should(HttpResponseMessage response) + { + return Task.CompletedTask; + } +} +#nullable restore diff --git a/test/MockHttp.Tests/Language/Flow/Response/TransferRateSpec.cs b/test/MockHttp.Tests/Language/Flow/Response/TransferRateSpec.cs new file mode 100644 index 00000000..95ac2c3b --- /dev/null +++ b/test/MockHttp.Tests/Language/Flow/Response/TransferRateSpec.cs @@ -0,0 +1,37 @@ +#nullable enable +using System.Diagnostics; +using System.Net; +using FluentAssertions; +using MockHttp.FluentAssertions; +using MockHttp.IO; +using MockHttp.Specs; + +namespace MockHttp.Language.Flow.Response; + +public class TransferRateSpec : ResponseSpec +{ + private const int DataSizeInBytes = 256 * 1024; // 256 KB + private const int BitRate = 512000; // 512 kbps = 64 KB/s + private static readonly TimeSpan ExpectedTotalTime = TimeSpan.FromSeconds(DataSizeInBytes / ((double)BitRate / 8)); + + private readonly byte[] _content = Enumerable.Range(0, DataSizeInBytes) + .Select((_, index) => (byte)(index % 256)) + .ToArray(); + + private readonly Stopwatch _stopwatch = new(); + + protected override void Given(IResponseBuilder with) + { + with.Body(new RateLimitedStream(new MemoryStream(_content), BitRate)); + _stopwatch.Start(); + } + + protected 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()); + } +} From 341ef966682e0b3599091ba83543c784a8cd0b9c Mon Sep 17 00:00:00 2001 From: skwasjer Date: Thu, 26 May 2022 15:09:55 +0200 Subject: [PATCH 6/6] chore: update docs/changelog/release notes --- CHANGELOG.md | 5 +++++ README.md | 5 ++++- src/MockHttp.Json/MockHttp.Json.csproj | 7 +++---- src/MockHttp.Json/README.md | 6 +++++- src/MockHttp.Server/MockHttp.Server.csproj | 6 +++--- src/MockHttp.Server/README.md | 6 +++++- src/MockHttp/MockHttp.csproj | 6 +++--- src/MockHttp/README.md | 5 ++++- 8 files changed, 32 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e0aa96c..bb726d64 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## v3.1.0-rcX + +- Added new fluent response API which provides more flexibility and control over the response. This new API replaces the current `.Respond()` API, and as such most of the old methods/overloads are now deprecated and will be removed in v4. +- Added `RateLimitedStream` helper to simulate network transfer rates. + ## v3.0.1 - fix: stop evaluating next matchers as soon as a failed match is encountered. diff --git a/README.md b/README.md index f868bb36..f30a9cc7 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,10 @@ mockHttp .Method("GET") .RequestUri("http://localhost/controller/*") ) - .RespondJson(HttpStatusCode.OK, new { id = 123, firstName = "John", lastName = "Doe" }) + .Respond(with => with + .StatusCode(200) + .JsonBody(new { id = 123, firstName = "John", lastName = "Doe" }) + ) .Verifiable(); var client = new HttpClient(mockHttp); diff --git a/src/MockHttp.Json/MockHttp.Json.csproj b/src/MockHttp.Json/MockHttp.Json.csproj index e2a76c6c..d743e008 100644 --- a/src/MockHttp.Json/MockHttp.Json.csproj +++ b/src/MockHttp.Json/MockHttp.Json.csproj @@ -12,10 +12,9 @@ https://github.com/skwasjer/MockHttp json mediatypeformatter httpclient test mock fake stub httpmock mockhttp httpmessagehandler moq - v3.0.0 - - Changed to System.Text.Json as default serializer, JSON.NET can be configured as default if desired (mockHttpHandler.UseNewtonsoftJson()). - - Added .NET 6.0 and .NET Framework 4.8/4.7.2/4.6.2 target framework support. - - Removed .NET Standard < 2 and .NET Framework 4.5 support. + v3.1.0-alpha1 + - Added new fluent response API which provides more flexibility and control over the response. This new API replaces the current `.Respond()` API, and as such most of the old methods/overloads are now deprecated and will be removed in v4. + - Added `RateLimitedStream` helper to simulate network transfer rates. README.md diff --git a/src/MockHttp.Json/README.md b/src/MockHttp.Json/README.md index acd8af3d..aff97705 100644 --- a/src/MockHttp.Json/README.md +++ b/src/MockHttp.Json/README.md @@ -16,7 +16,11 @@ mockHttp .Method("GET") .RequestUri("http://localhost/controller/*") ) - .RespondJson(HttpStatusCode.OK, new { id = 123, firstName = "John", lastName = "Doe" }) + .Respond(with => with + .StatusCode(200) + .JsonBody("Hello world") + .ContentType("text/html") + ) .Verifiable(); var client = new HttpClient(mockHttp); diff --git a/src/MockHttp.Server/MockHttp.Server.csproj b/src/MockHttp.Server/MockHttp.Server.csproj index b7ae1309..c932edf6 100644 --- a/src/MockHttp.Server/MockHttp.Server.csproj +++ b/src/MockHttp.Server/MockHttp.Server.csproj @@ -13,9 +13,9 @@ https://github.com/skwasjer/MockHttp httpclient httpserver test mock fake stub httpmock mockhttp httpmessagehandler moq - v3.0.0 - - Added .NET 6 and .NET Core 3.1 target framework support. - - Removed .NET Standard 2.x target frameworks support. + v3.1.0-alpha1 + - Added new fluent response API which provides more flexibility and control over the response. This new API replaces the current `.Respond()` API, and as such most of the old methods/overloads are now deprecated and will be removed in v4. + - Added `RateLimitedStream` helper to simulate network transfer rates. README.md diff --git a/src/MockHttp.Server/README.md b/src/MockHttp.Server/README.md index c727593d..a5deb76e 100644 --- a/src/MockHttp.Server/README.md +++ b/src/MockHttp.Server/README.md @@ -13,7 +13,11 @@ MockHttpHandler mockHttp = new MockHttpHandler(); // Configure setup(s). mockHttp .When(matching => matching.Method("GET")) - .Respond(HttpStatusCode.OK) + .Respond(with => with + .StatusCode(200) + .JsonBody("Hello world") + .ContentType("text/html") + ) .Verifiable(); // Mount the mock handler in a server. diff --git a/src/MockHttp/MockHttp.csproj b/src/MockHttp/MockHttp.csproj index 8bbd8369..486307fb 100644 --- a/src/MockHttp/MockHttp.csproj +++ b/src/MockHttp/MockHttp.csproj @@ -11,9 +11,9 @@ https://github.com/skwasjer/MockHttp httpclient test mock fake stub httpmock mockhttp httpmessagehandler moq - v3.0.0 - - Added .NET 6.0 and .NET Framework 4.8/4.7.2/4.6.2 target framework support. - - Removed .NET Standard < 2 and .NET Framework 4.5 support. + v3.1.0-alpha1 + - Added new fluent response API which provides more flexibility and control over the response. This new API replaces the current `.Respond()` API, and as such most of the old methods/overloads are now deprecated and will be removed in v4. + - Added `RateLimitedStream` helper to simulate network transfer rates. README.md diff --git a/src/MockHttp/README.md b/src/MockHttp/README.md index 6c47f8cc..545cbb99 100644 --- a/src/MockHttp/README.md +++ b/src/MockHttp/README.md @@ -15,7 +15,10 @@ mockHttp .Method("GET") .RequestUri("http://localhost/controller/*") ) - .Respond(HttpStatusCode.OK) + .Respond(with => with + .StatusCode(200) + .JsonBody(new { id = 123, firstName = "John", lastName = "Doe" }) + ) .Verifiable(); var client = new HttpClient(mockHttp);