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/Constants.cs b/src/MockHttp.Json/Constants.cs
deleted file mode 100644
index 4e5d1adb..00000000
--- a/src/MockHttp.Json/Constants.cs
+++ /dev/null
@@ -1,6 +0,0 @@
-namespace MockHttp.Json;
-
-internal static class MediaTypes
-{
- public const string JsonMediaTypeWithUtf8 = "application/json; charset=utf-8";
-}
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..09858fa3
--- /dev/null
+++ b/src/MockHttp.Json/Extensions/ResponseBuilderExtensions.cs
@@ -0,0 +1,61 @@
+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;
+
+///
+/// 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 IWithContentResult JsonBody(this IWithContent 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 IWithContentResult JsonBody(this IWithContent 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/MediaTypes.cs b/src/MockHttp.Json/MediaTypes.cs
new file mode 100644
index 00000000..935df3bf
--- /dev/null
+++ b/src/MockHttp.Json/MediaTypes.cs
@@ -0,0 +1,6 @@
+namespace MockHttp.Json;
+
+internal static class MediaTypes
+{
+ public const string JsonMediaType = "application/json";
+}
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/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..004c0161
--- /dev/null
+++ b/src/MockHttp.Json/Newtonsoft/ResponseBuilderExtensions.cs
@@ -0,0 +1,54 @@
+using System.Text;
+using MockHttp.Language.Flow.Response;
+using MockHttp.Language.Response;
+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 IWithContentResult JsonBody
+ (
+ this IWithContent builder,
+ T jsonContent,
+ Encoding? encoding = null,
+ JsonSerializerSettings? serializerSettings = null
+ )
+ {
+ return builder.JsonBody(_ => jsonContent, encoding, 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 .
+ // ReSharper disable once MemberCanBePrivate.Global
+ public static IWithContentResult JsonBody
+ (
+ this IWithContent builder,
+ Func jsonContentFactory,
+ Encoding? encoding = null,
+ JsonSerializerSettings? serializerSettings = null
+ )
+ {
+ return builder.JsonBody(jsonContentFactory, encoding, new NewtonsoftAdapter(serializerSettings));
+ }
+}
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.Json/RespondsExtensions.cs b/src/MockHttp.Json/RespondsExtensions.cs
index 9849c5ad..9b54e9d3 100644
--- a/src/MockHttp.Json/RespondsExtensions.cs
+++ b/src/MockHttp.Json/RespondsExtensions.cs
@@ -1,13 +1,17 @@
using System.Net;
using System.Net.Http.Headers;
+using System.Text;
using MockHttp.Language;
using MockHttp.Language.Flow;
+using MockHttp.Language.Flow.Response;
+using MockHttp.Language.Response;
namespace MockHttp.Json;
///
/// JSON extensions for .
///
+[Obsolete(DeprecationWarnings.RespondsExtensions, false)]
public static class RespondsExtensions
{
///
@@ -15,6 +19,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 +31,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 +44,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 +57,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 +70,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 +83,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 +97,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 +111,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 +126,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
{
@@ -122,16 +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 = mediaType?.CharSet is not null
+ ? Encoding.GetEncoding(mediaType.CharSet)
+ : null;
- return responds.RespondUsing(
- new JsonResponseStrategy(
- statusCode,
- content,
- mt,
- adapter
- )
- );
+ 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));
}
///
@@ -140,6 +162,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 +175,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 +189,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 +203,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..3dd9a5ff
--- /dev/null
+++ b/src/MockHttp.Json/SystemTextJson/ResponseBuilderExtensions.cs
@@ -0,0 +1,54 @@
+using System.Text;
+using System.Text.Json;
+using MockHttp.Language.Flow.Response;
+using MockHttp.Language.Response;
+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 IWithContentResult JsonBody
+ (
+ this IWithContent builder,
+ T jsonContent,
+ Encoding? encoding = null,
+ JsonSerializerOptions? serializerOptions = null
+ )
+ {
+ return builder.JsonBody(_ => jsonContent, encoding, 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 .
+ // ReSharper disable once MemberCanBePrivate.Global
+ public static IWithContentResult JsonBody
+ (
+ this IWithContent builder,
+ Func jsonContentFactory,
+ Encoding? encoding = null,
+ JsonSerializerOptions? serializerOptions = null
+ )
+ {
+ return builder.JsonBody(jsonContentFactory, encoding, new SystemTextJsonAdapter(serializerOptions));
+ }
+}
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/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 47b100b4..747a9a71 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,10 +77,11 @@ 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
{
- return responds.Respond(() => new HttpResponseMessage(statusCode));
+ return responds.Respond(with => with.StatusCode(statusCode));
}
///
@@ -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
{
@@ -157,13 +165,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 ?? new MediaTypeHeaderValue(MediaTypes.DefaultMediaType))
+ );
}
///
@@ -173,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
{
@@ -187,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 ?? "text/plain") { CharSet = (encoding ?? Encoding.UTF8).WebName });
+ return responds.Respond(statusCode, content, new MediaTypeHeaderValue(mediaType ?? MediaTypes.DefaultMediaType) { CharSet = (encoding ?? ResponseBuilder.DefaultWebEncoding).WebName });
}
///
@@ -198,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
{
@@ -210,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
{
@@ -222,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
{
@@ -235,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
{
@@ -247,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
{
@@ -260,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
{
@@ -273,14 +287,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 ?? new MediaTypeHeaderValue(MediaTypes.DefaultMediaType))
+ );
}
///
@@ -290,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
{
@@ -303,7 +315,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 ?? new MediaTypeHeaderValue(MediaTypes.DefaultMediaType))
+ );
}
///
@@ -311,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
{
@@ -323,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
{
@@ -336,16 +354,18 @@ 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))
+ );
}
///
/// 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
{
@@ -376,6 +398,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.ClientTimeout(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/ListExtensions.cs b/src/MockHttp/Extensions/ListExtensions.cs
new file mode 100644
index 00000000..8c3fd8ff
--- /dev/null
+++ b/src/MockHttp/Extensions/ListExtensions.cs
@@ -0,0 +1,45 @@
+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]);
+ }
+
+ 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
new file mode 100644
index 00000000..8ec183f0
--- /dev/null
+++ b/src/MockHttp/Extensions/ResponseBuilderExtensions.cs
@@ -0,0 +1,330 @@
+#nullable enable
+using System.ComponentModel;
+using System.Globalization;
+using System.Net;
+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;
+
+///
+/// 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 IWithStatusCodeResult StatusCode(this IWithStatusCode builder, int statusCode)
+ {
+ if (builder is null)
+ {
+ throw new ArgumentNullException(nameof(builder));
+ }
+
+ return builder.StatusCode((HttpStatusCode)statusCode);
+ }
+
+ ///
+ /// 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 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));
+ }
+
+ 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 IWithContentResult Body(this IWithContent 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 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));
+ }
+
+ if (count < 0 || count > content.Length - offset)
+ {
+ throw new ArgumentOutOfRangeException(nameof(count));
+ }
+
+ return builder.Body(_ => Task.FromResult(new ByteArrayContent(content, offset, count)));
+ }
+
+ ///
+ /// 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 stream content.
+ /// The builder to continue chaining additional behaviors.
+ /// Thrown when or is .
+ /// Thrown when the stream does not support reading.
+ public static IWithContentResult Body(this IWithContent builder, Stream content)
+ {
+ if (content is null)
+ {
+ throw new ArgumentNullException(nameof(content));
+ }
+
+ if (!content.CanRead)
+ {
+ throw new ArgumentException("Cannot read from stream.", nameof(content));
+ }
+
+ 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);
+ buffer = ms.ToArray();
+ }
+
+ return builder.Body(buffer);
+ }
+
+ ///
+ /// 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 builder to continue chaining additional behaviors.
+ /// Thrown when or is .
+ public static IWithContentResult Body(this IWithContent builder, Func contentFactory)
+ {
+ if (builder is null)
+ {
+ throw new ArgumentNullException(nameof(builder));
+ }
+
+ if (contentFactory is null)
+ {
+ throw new ArgumentNullException(nameof(contentFactory));
+ }
+
+ return builder.Body(_ =>
+ {
+ Stream stream = contentFactory();
+ if (!stream.CanRead)
+ {
+ throw new InvalidOperationException("Cannot read from stream.");
+ }
+
+ return Task.FromResult(new StreamContent(stream));
+ });
+ }
+
+ ///
+ /// Sets the media type for the response. Will be ignored if no content is set.
+ ///
+ /// The builder.
+ /// The media type.
+ /// The optional encoding.
+ /// The builder to continue chaining additional behaviors.
+ /// Thrown when or is .
+ /// Throw if the is invalid.
+ public static IWithHeadersResult ContentType(this IWithContentType builder, string mediaType, Encoding? encoding = null)
+ {
+ if (builder is null)
+ {
+ throw new ArgumentNullException(nameof(builder));
+ }
+
+ if (mediaType is null)
+ {
+ throw new ArgumentNullException(nameof(mediaType));
+ }
+
+ var mediaTypeHeaderValue = MediaTypeHeaderValue.Parse(mediaType);
+ if (encoding is not null)
+ {
+ mediaTypeHeaderValue.CharSet = encoding.WebName;
+ }
+
+ return builder.ContentType(mediaTypeHeaderValue);
+ }
+
+ ///
+ /// Adds a HTTP header value.
+ ///
+ /// The builder.
+ /// The header name.
+ /// 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, params T?[] value)
+ {
+ if (builder is null)
+ {
+ throw new ArgumentNullException(nameof(builder));
+ }
+
+ if (name is null)
+ {
+ throw new ArgumentNullException(nameof(name));
+ }
+
+ // ReSharper disable once ConditionalAccessQualifierIsNonNullableAccordingToAPIContract
+ return builder.Headers(new HttpHeadersCollection { { name, value?.Select(ConvertToString).ToArray() ?? Array.Empty() } });
+ }
+
+ ///
+ /// 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 IWithResponse ClientTimeout(this IResponseBuilder builder, TimeSpan? timeoutAfter = null)
+ {
+ if (builder is null)
+ {
+ throw new ArgumentNullException(nameof(builder));
+ }
+
+ builder.Behaviors.Replace(new TimeoutBehavior(timeoutAfter ?? TimeSpan.Zero));
+ return builder;
+ }
+
+ ///
+ /// 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. 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 IWithStatusCodeResult ServerTimeout(this IResponseBuilder builder, TimeSpan? timeoutAfter = null)
+ {
+ if (builder is null)
+ {
+ throw new ArgumentNullException(nameof(builder));
+ }
+
+ TimeSpan timeout = timeoutAfter ?? TimeSpan.Zero;
+ IWithStatusCodeResult statusCodeResult = builder.StatusCode(HttpStatusCode.RequestTimeout);
+ statusCodeResult.Latency(NetworkLatency.Between(timeout, timeout.Add(TimeSpan.FromMilliseconds(1))));
+ return statusCodeResult;
+ }
+
+ ///
+ /// 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 IWithResponse Latency(this IWithResponse builder, NetworkLatency latency)
+ {
+ if (builder is null)
+ {
+ throw new ArgumentNullException(nameof(builder));
+ }
+
+ builder.Behaviors.Replace(new NetworkLatencyBehavior(latency));
+ 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 IWithResponse Latency(this IWithResponse builder, Func latency)
+ {
+ return builder.Latency(latency());
+ }
+
+ private static string? ConvertToString(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/IResponseBuilder.cs b/src/MockHttp/IResponseBuilder.cs
new file mode 100644
index 00000000..f4883509
--- /dev/null
+++ b/src/MockHttp/IResponseBuilder.cs
@@ -0,0 +1,17 @@
+using System.ComponentModel;
+using MockHttp.Language.Response;
+
+namespace MockHttp;
+
+///
+/// A builder to compose HTTP responses via a behavior pipeline.
+///
+[EditorBrowsable(EditorBrowsableState.Never)]
+public interface IResponseBuilder
+ : IWithResponse,
+ IWithStatusCode,
+ IWithContent,
+ IWithHeaders,
+ IFluentInterface
+{
+}
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..d1622014
--- /dev/null
+++ b/src/MockHttp/Language/Flow/Response/ResponseBuilder.cs
@@ -0,0 +1,105 @@
+#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.Replace(new StatusCodeBehavior(statusCode));
+ return this;
+ }
+
+ ///
+ public IWithContentResult Body(Func> httpContentFactory)
+ {
+ Behaviors.Replace(new HttpContentBehavior(httpContentFactory));
+ return this;
+ }
+
+ ///
+ public IWithHeadersResult ContentType(MediaTypeHeaderValue mediaType)
+ {
+ 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(headerList));
+ 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..c1919f33
--- /dev/null
+++ b/src/MockHttp/Language/Response/IWithHeaders.cs
@@ -0,0 +1,22 @@
+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 .
+ /// Thrown when is empty.
+ 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/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/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/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);
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/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..e02bd438
--- /dev/null
+++ b/src/MockHttp/Responses/HttpHeaderBehavior.cs
@@ -0,0 +1,63 @@
+#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)
+ {
+ // ReSharper disable once ConditionalAccessQualifierIsNonNullableAccordingToAPIContract
+ _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))
+ {
+ 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.TryAddWithoutValidation(header.Key, header.Value);
+ responseMessage.Headers.TryAddWithoutValidation(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/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/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/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.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;
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