Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 3 additions & 8 deletions Mockly.ApiVerificationTests/ApprovedApi/net472.verified.txt
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ namespace Mockly
public System.Net.Http.HttpMethod Method { get; }
public string Path { get; }
public string Query { get; }
public byte[]? RawBody { get; }
public System.Net.Http.HttpResponseMessage Response { get; }
public string Scheme { get; }
public int Sequence { get; set; }
Expand Down Expand Up @@ -56,12 +57,6 @@ namespace Mockly
{
T Build();
}
public class Matcher
{
public Matcher(System.Func<Mockly.RequestInfo, System.Threading.Tasks.Task<bool>> predicate, string? displayText) { }
public System.Threading.Tasks.Task<bool> IsMatch(Mockly.RequestInfo request) { }
public override string ToString() { }
}
public class RequestCollection : System.Collections.Generic.IEnumerable<Mockly.CapturedRequest>, System.Collections.IEnumerable
{
public RequestCollection() { }
Expand All @@ -74,12 +69,13 @@ namespace Mockly
}
public class RequestInfo
{
public RequestInfo(System.Net.Http.HttpRequestMessage request, string? body) { }
public RequestInfo(System.Net.Http.HttpRequestMessage request, byte[]? rawBody) { }
public string? Body { get; }
public string? ContentType { get; }
public System.Net.Http.Headers.HttpRequestHeaders Headers { get; }
public System.Net.Http.HttpMethod Method { get; set; }
public System.Collections.Generic.IDictionary<string, object?> Properties { get; }
public byte[]? RawBody { get; }
public System.Uri? Uri { get; }
public System.Version Version { get; set; }
public bool IsBodyLikelyTextual() { }
Expand All @@ -91,7 +87,6 @@ namespace Mockly
public class RequestMock
{
public RequestMock() { }
public System.Collections.Generic.IEnumerable<Mockly.Matcher> CustomMatchers { get; }
public string? HostPattern { get; set; }
public int InvocationCount { get; }
public uint? MaxInvocations { get; }
Expand Down
11 changes: 3 additions & 8 deletions Mockly.ApiVerificationTests/ApprovedApi/net8.0.verified.txt
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ namespace Mockly
public System.Net.Http.HttpRequestOptions Options { get; }
public string Path { get; }
public string Query { get; }
public byte[]? RawBody { get; }
public System.Net.Http.HttpResponseMessage Response { get; }
public string Scheme { get; }
public int Sequence { get; set; }
Expand Down Expand Up @@ -58,12 +59,6 @@ namespace Mockly
{
T Build();
}
public class Matcher
{
public Matcher(System.Func<Mockly.RequestInfo, System.Threading.Tasks.Task<bool>> predicate, string? displayText) { }
public System.Threading.Tasks.Task<bool> IsMatch(Mockly.RequestInfo request) { }
public override string ToString() { }
}
public class RequestCollection : System.Collections.Generic.IEnumerable<Mockly.CapturedRequest>, System.Collections.IEnumerable
{
public RequestCollection() { }
Expand All @@ -76,12 +71,13 @@ namespace Mockly
}
public class RequestInfo
{
public RequestInfo(System.Net.Http.HttpRequestMessage request, string? body) { }
public RequestInfo(System.Net.Http.HttpRequestMessage request, byte[]? rawBody) { }
public string? Body { get; }
public string? ContentType { get; }
public System.Net.Http.Headers.HttpRequestHeaders Headers { get; }
public System.Net.Http.HttpMethod Method { get; set; }
public System.Net.Http.HttpRequestOptions Options { get; }
public byte[]? RawBody { get; }
public System.Uri? Uri { get; }
public System.Version Version { get; set; }
public System.Net.Http.HttpVersionPolicy VersionPolicy { get; set; }
Expand All @@ -94,7 +90,6 @@ namespace Mockly
public class RequestMock
{
public RequestMock() { }
public System.Collections.Generic.IEnumerable<Mockly.Matcher> CustomMatchers { get; }
public string? HostPattern { get; set; }
public int InvocationCount { get; }
public uint? MaxInvocations { get; }
Expand Down
96 changes: 92 additions & 4 deletions Mockly.Specs/HttpMockSpecs.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1051,6 +1051,7 @@ await client.PostAsync("https://localhost/api/test",
// Assert
request.Should().NotBeNull();
request.Body.Should().BeNull();
request.RawBody.Should().BeNull();
}

[Fact]
Expand Down Expand Up @@ -1107,6 +1108,59 @@ await client.SendAsync(new HttpRequestMessage(new HttpMethod("PATCH"), "https://
requests.Should().ContainSingle().Which.ToString().Should().Be("PATCH https://localhost/api/update");
}

[Fact]
public async Task Exposes_the_textual_body_of_a_captured_request()
{
// Arrange
var mock = new HttpMock();
var requests = new RequestCollection();

mock.ForPost()
.WithPath("/api/update")
.CollectingRequestsIn(requests)
.RespondsWithStatus(HttpStatusCode.NoContent);

var client = mock.GetClient();

// Act
await client.PostAsync("https://localhost/api/update",
new StringContent("hello", Encoding.UTF8, "text/plain"));

// Assert
CapturedRequest capturedRequest = requests.Should().ContainSingle().Which;
capturedRequest.Body.Should().Be("hello");
capturedRequest.RawBody.Should().Equal("hello"u8.ToArray());
}

[Fact]
public async Task Exposes_the_raw_bytes_of_a_binary_captured_request()
{
// Arrange
var mock = new HttpMock();
var requests = new RequestCollection();

mock.ForPost()
.WithPath("/api/update")
.CollectingRequestsIn(requests)
.RespondsWithStatus(HttpStatusCode.NoContent);

var client = mock.GetClient();

// Act
await client.PostAsync("https://localhost/api/update", new ByteArrayContent([1, 2, 3])
{
Headers =
{
ContentType = new MediaTypeHeaderValue("application/octet-stream")
}
});

// Assert
CapturedRequest capturedRequest = requests.Should().ContainSingle().Which;
capturedRequest.Body.Should().BeNull();
capturedRequest.RawBody.Should().Equal(1, 2, 3);
}

[Fact]
public async Task Tracks_unexpected_requests()
{
Expand Down Expand Up @@ -2060,8 +2114,43 @@ public async Task An_async_responder_receives_request_info()
// Assert
capturedInfo.Should().NotBeNull();
capturedInfo.Method.Should().Be(HttpMethod.Post);
capturedInfo.Uri.AbsolutePath.Should().Be("/api/info");
capturedInfo.Uri.Should().NotBeNull();
capturedInfo.Uri!.AbsolutePath.Should().Be("/api/info");
capturedInfo.Body.Should().Be("hello");
capturedInfo.RawBody.Should().Equal("hello"u8.ToArray());
}

[Fact]
public async Task An_async_responder_receives_binary_request_body_as_raw_bytes()
{
// Arrange
var mock = new HttpMock();
RequestInfo capturedInfo = null;

mock.ForPost()
.WithPath("/api/binary-info")
.RespondsWith(async request =>
{
capturedInfo = request;
await Task.Yield();
return new HttpResponseMessage(HttpStatusCode.OK);
});

var client = mock.GetClient();

// Act
await client.PostAsync("https://localhost/api/binary-info", new ByteArrayContent([1, 2, 3])
{
Headers =
{
ContentType = new MediaTypeHeaderValue("application/octet-stream")
}
});

// Assert
capturedInfo.Should().NotBeNull();
capturedInfo.Body.Should().BeNull();
capturedInfo.RawBody.Should().Equal(1, 2, 3);
}

[Fact]
Expand Down Expand Up @@ -3427,7 +3516,7 @@ public async Task A_custom_responder_can_be_appended_to_the_sequence()
}

[Fact]
public void The_Responder_property_still_exposes_the_first_response()
public void The_responder_property_still_exposes_the_first_response()
{
// Arrange
var mock = new HttpMock();
Expand All @@ -3437,7 +3526,7 @@ public void The_Responder_property_still_exposes_the_first_response()

// Act
var uninvoked = mock.GetUninvokedMocks().First();
var request = new RequestInfo(new HttpRequestMessage(HttpMethod.Get, "https://localhost/resource"), body: null);
var request = new RequestInfo(new HttpRequestMessage(HttpMethod.Get, "https://localhost/resource"), rawBody: null);
var firstResponse = uninvoked.Responder(request);

// Assert
Expand Down Expand Up @@ -4470,4 +4559,3 @@ await act.Should().ThrowAsync<UnexpectedRequestException>()
}
}


8 changes: 8 additions & 0 deletions Mockly/CapturedRequest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,14 @@ public string? Body
get => request.Body;
}

/// <summary>
/// Gets the raw body bytes of the captured HTTP request.
/// </summary>
public byte[]? RawBody
Comment thread
dennisdoomen marked this conversation as resolved.
Fixed
{
get => request.RawBody;
}

/// <summary>
/// The collection of HTTP request headers associated with the captured request.
/// </summary>
Expand Down
14 changes: 7 additions & 7 deletions Mockly/HttpMock.cs
Original file line number Diff line number Diff line change
Expand Up @@ -328,7 +328,8 @@ private async Task ThrowDetailedException(RequestInfo request)

var messageBuilder = new StringBuilder();
messageBuilder.AppendLine("Unexpected request to:");
messageBuilder.AppendLine($" {request.Method} {request.Uri} with body of {request.Body?.Length ?? 0} bytes");
int bodyLength = request.RawBody?.Length ?? request.Body?.Length ?? 0;
messageBuilder.AppendLine($" {request.Method} {request.Uri} with body of {bodyLength} bytes");

messageBuilder.AppendLine();
messageBuilder.AppendLine("Note that you can further inspect the executed requests through the HttpMock.Requests property.");
Expand Down Expand Up @@ -370,12 +371,12 @@ private async Task ThrowDetailedException(RequestInfo request)
}
}

if (request.Body is not null && request.Body.Length > 0)
if ((request.RawBody?.Length ?? 0) > 0 || !string.IsNullOrEmpty(request.Body))
{
messageBuilder.AppendLine();
messageBuilder.AppendLine($"Body ({request.ContentType}):");

if (request.IsBodyLikelyTextual())
if (request.IsBodyLikelyTextual() && request.Body is not null)
{
messageBuilder.AppendLine($" \"{request.Body}\"");
}
Expand All @@ -394,14 +395,13 @@ private async Task ThrowDetailedException(RequestInfo request)
/// </summary>
private async Task<RequestInfo> BuildRequestInfo(HttpRequestMessage httpRequest)
{
string? body = null;
byte[]? rawBody = null;
if (PrefetchBody && httpRequest.Content is not null)
{
body = await httpRequest.Content.ReadAsStringAsync();
rawBody = await httpRequest.Content.ReadAsByteArrayAsync();
}

var request = new RequestInfo(httpRequest, body);
return request;
return new RequestInfo(httpRequest, rawBody);
}

/// <summary>
Expand Down
2 changes: 1 addition & 1 deletion Mockly/Matcher.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
namespace Mockly;

public class Matcher(Func<RequestInfo, Task<bool>> predicate, string? displayText)
internal class Matcher(Func<RequestInfo, Task<bool>> predicate, string? displayText)
{
public override string ToString() => displayText ?? "Custom matcher";

Expand Down
59 changes: 56 additions & 3 deletions Mockly/RequestInfo.cs
Original file line number Diff line number Diff line change
@@ -1,18 +1,39 @@
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;

namespace Mockly;

public class RequestInfo(HttpRequestMessage request, string? body)
/// <summary>
/// Provides information about a request that was captured by a mock.
/// </summary>
public class RequestInfo
{
private readonly HttpRequestMessage request;

public RequestInfo(HttpRequestMessage request, byte[]? rawBody)
{
this.request = request;
RawBody = rawBody;
Body = DeserializeBodyIfTextual(rawBody);
}

/// <summary>
/// Gets the URI of the HTTP request, representing the full address, including the scheme, host, path, and query string, if present.
/// </summary>
public Uri? Uri => request.RequestUri;

public string? Body { get; } = body;
public string? Body { get; }

/// <summary>
/// The request body as raw bytes, if prefetched.
/// </summary>
public byte[]? RawBody { get; }

/// <summary>
/// The content type of the request body, if any.
/// </summary>
public string? ContentType { get; } = request.Content?.Headers.ContentType?.MediaType;
public string? ContentType => request.Content?.Headers.ContentType?.MediaType;

public HttpRequestHeaders Headers
{
Expand Down Expand Up @@ -103,4 +124,36 @@ public bool IsBodyLikelyTextual()
"application/graphql" or
"application/sql";
}

/// <summary>
/// Deserializes the provided raw body byte array into a textual representation if it is likely to be textual.
/// </summary>
private string? DeserializeBodyIfTextual(byte[]? rawBody)
{
if (rawBody is null || rawBody.Length == 0 || !IsBodyLikelyTextual())
{
return null;
}

Encoding encoding = GetEncoding() ?? Encoding.UTF8;
return encoding.GetString(rawBody);
}

private Encoding? GetEncoding()
{
string? charset = request.Content?.Headers.ContentType?.CharSet;
if (string.IsNullOrWhiteSpace(charset))
{
return null;
}

try
{
return Encoding.GetEncoding(charset);
}
catch (ArgumentException)
{
return null;
}
}
}
2 changes: 1 addition & 1 deletion Mockly/RequestMock.cs
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ public class RequestMock
/// Gets the custom matchers that are evaluated in addition to the standard route criteria.
/// All matchers must return <c>true</c> for the mock to match a request.
/// </summary>
public IEnumerable<Matcher> CustomMatchers { get; internal init; } = [];
internal IEnumerable<Matcher> CustomMatchers { get; init; } = [];

/// <summary>
/// Gets or sets the responder used to produce a response for a matched request.
Expand Down
Loading
Loading