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
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ namespace Mockly
public Mockly.SequencedResponseBuilder RespondsWithProblemDetails(System.Net.HttpStatusCode statusCode, string? title = null, string? detail = null, string? type = null, string? instance = null, System.Collections.Generic.IDictionary<string, object?>? extensions = null) { }
public Mockly.SequencedResponseBuilder RespondsWithStatus(System.Net.HttpStatusCode statusCode) { }
public Mockly.SequencedResponseBuilder RespondsWithStream(System.IO.Stream stream, string contentType) { }
public Mockly.RequestMockBuilder TreatBodyAsTextual() { }
public Mockly.RequestMockBuilder Using(System.Text.Json.JsonSerializerOptions options) { }
public Mockly.RequestMockBuilder With(System.Func<Mockly.RequestInfo, System.Threading.Tasks.Task<bool>> matcher, [System.Runtime.CompilerServices.CallerArgumentExpression("matcher")] string? matcherText = null) { }
public Mockly.RequestMockBuilder With(System.Func<Mockly.RequestInfo, bool> matcher, [System.Runtime.CompilerServices.CallerArgumentExpression("matcher")] string? matcherText = null) { }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ namespace Mockly
public Mockly.SequencedResponseBuilder RespondsWithProblemDetails(System.Net.HttpStatusCode statusCode, string? title = null, string? detail = null, string? type = null, string? instance = null, System.Collections.Generic.IDictionary<string, object?>? extensions = null) { }
public Mockly.SequencedResponseBuilder RespondsWithStatus(System.Net.HttpStatusCode statusCode) { }
public Mockly.SequencedResponseBuilder RespondsWithStream(System.IO.Stream stream, string contentType) { }
public Mockly.RequestMockBuilder TreatBodyAsTextual() { }
public Mockly.RequestMockBuilder Using(System.Text.Json.JsonSerializerOptions options) { }
public Mockly.RequestMockBuilder With(System.Func<Mockly.RequestInfo, System.Threading.Tasks.Task<bool>> matcher, [System.Runtime.CompilerServices.CallerArgumentExpression("matcher")] string? matcherText = null) { }
public Mockly.RequestMockBuilder With(System.Func<Mockly.RequestInfo, bool> matcher, [System.Runtime.CompilerServices.CallerArgumentExpression("matcher")] string? matcherText = null) { }
Expand Down
47 changes: 47 additions & 0 deletions Mockly.Specs/HttpMockSpecs.cs
Original file line number Diff line number Diff line change
Expand Up @@ -880,6 +880,53 @@
response.StatusCode.Should().Be(HttpStatusCode.NoContent);
}

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

mock.ForPost()
.WithPath("/api/test")
.WithBody("*something*")
.RespondsWithStatus(HttpStatusCode.NoContent);

var client = mock.GetClient();

var content = new ByteArrayContent(Encoding.UTF8.GetBytes("a body with something in it"));

Check notice

Code scanning / InspectCode

Use UTF-8 string literal Note

'Encoding.GetBytes' invocation can be replaced with a UTF-8 string literal
content.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream");

// Act
var action = async () => await client.PostAsync("https://localhost/api/test", content);

// Assert
await action.Should().ThrowAsync<UnexpectedRequestException>();
}

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

mock.ForPost()
.WithPath("/api/test")
.WithBody("*something*")
.TreatBodyAsTextual()
.RespondsWithStatus(HttpStatusCode.NoContent);

var client = mock.GetClient();

var content = new ByteArrayContent(Encoding.UTF8.GetBytes("a body with something in it"));

Check notice

Code scanning / InspectCode

Use UTF-8 string literal Note

'Encoding.GetBytes' invocation can be replaced with a UTF-8 string literal
content.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream");

// Act
var response = await client.PostAsync("https://localhost/api/test", content);

// Assert
response.StatusCode.Should().Be(HttpStatusCode.NoContent);
}

[Fact]
public async Task Can_match_the_body_against_a_json_string_ignoring_layout()
{
Expand Down
6 changes: 5 additions & 1 deletion Mockly/HttpMock.cs
Original file line number Diff line number Diff line change
Expand Up @@ -401,7 +401,11 @@ private async Task<RequestInfo> BuildRequestInfo(HttpRequestMessage httpRequest)
rawBody = await httpRequest.Content.ReadAsByteArrayAsync();
}

return new RequestInfo(httpRequest, rawBody);
// If any registered mock opted into TreatBodyAsTextual(), decode the body as text even when its
// Content-Type isn't recognized as textual.
bool forceTextualBody = mocks.Any(mock => mock.ForceTextualBody);

return new RequestInfo(httpRequest, rawBody) { ForceTextualBody = forceTextualBody };
}

/// <summary>
Expand Down
20 changes: 14 additions & 6 deletions Mockly/RequestInfo.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,22 @@ public RequestInfo(HttpRequestMessage request, byte[]? rawBody)
{
this.request = request;
RawBody = rawBody;
Body = DeserializeBodyIfTextual(rawBody);
}

/// <summary>
/// When set, forces <see cref="Body"/> to be populated from <see cref="RawBody"/> regardless of whether
/// <see cref="IsBodyLikelyTextual"/> recognizes the Content-Type as textual. Set internally by
/// <see cref="HttpMock"/> when a registered mock opted in via
/// <see cref="RequestMockBuilder.TreatBodyAsTextual"/>.
/// </summary>
internal bool ForceTextualBody { get; init; }

/// <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; }
public string? Body => DeserializeBodyIfTextual();

/// <summary>
/// The request body as raw bytes, if prefetched.
Expand Down Expand Up @@ -134,17 +141,18 @@ public bool IsBodyLikelyTextual()
}

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

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

private Encoding? GetEncoding()
Expand Down
6 changes: 6 additions & 0 deletions Mockly/RequestMock.cs
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,12 @@ public class RequestMock
/// </summary>
internal IEnumerable<Matcher> CustomMatchers { get; init; } = [];

/// <summary>
/// Gets whether this mock forces the captured request body to be treated as textual, even when its
/// Content-Type isn't recognized as textual. See <see cref="RequestMockBuilder.TreatBodyAsTextual"/>.
/// </summary>
internal bool ForceTextualBody { get; init; }

/// <summary>
/// Gets or sets the responder used to produce a response for a matched request.
/// </summary>
Expand Down
22 changes: 22 additions & 0 deletions Mockly/RequestMockBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ public class RequestMockBuilder
private string? hostPattern = "localhost";
private RequestCollection? requestCollection;
private JsonSerializerOptions? jsonSerializerOptions;
private bool forceTextualBody;

internal RequestMockBuilder(HttpMock mockBuilder, HttpMethod method)
{
Expand Down Expand Up @@ -317,6 +318,21 @@ public RequestMockBuilder WithBody(string wildcardPattern)
$"body matches wildcard pattern \"{wildcardPattern}\"");
}

/// <summary>
/// Forces the captured request body to be treated as textual for this mock, even when its Content-Type
/// isn't recognized as textual (e.g. an unrecognized or binary-looking media type).
/// </summary>
/// <remarks>
/// Use this as an escape hatch when the request body is actually text but uses a Content-Type that Mockly
/// doesn't recognize as textual, so that <see cref="RequestInfo.Body"/> gets populated and body-based
/// matchers such as <see cref="WithBody(string)"/> can match against it.
/// </remarks>
public RequestMockBuilder TreatBodyAsTextual()
{
forceTextualBody = true;
return this;
}

/// <summary>
/// Configures the request mock to match requests that contain the specified header, regardless of its value.
/// </summary>
Expand Down Expand Up @@ -472,6 +488,7 @@ private SequencedResponseBuilder CreateResponse(Func<RequestInfo, HttpResponseMe
Scheme = scheme,
HostPattern = hostPattern,
CustomMatchers = customMatchers,
ForceTextualBody = forceTextualBody,
RequestCollection = requestCollection,
Responder = responder
};
Expand Down Expand Up @@ -593,6 +610,7 @@ public SequencedResponseBuilder RespondsWithProblemDetails(
Scheme = scheme,
HostPattern = hostPattern,
CustomMatchers = customMatchers,
ForceTextualBody = forceTextualBody,
RequestCollection = requestCollection,
Responder = _ =>
{
Expand Down Expand Up @@ -783,6 +801,7 @@ public SequencedResponseBuilder RespondsWithFile(string path, string? contentTyp
Scheme = scheme,
HostPattern = hostPattern,
CustomMatchers = customMatchers,
ForceTextualBody = forceTextualBody,
RequestCollection = requestCollection,
Responder = _ =>
{
Expand Down Expand Up @@ -833,6 +852,7 @@ public SequencedResponseBuilder RespondsWithBytes(byte[] content, string content
Scheme = scheme,
HostPattern = hostPattern,
CustomMatchers = customMatchers,
ForceTextualBody = forceTextualBody,
RequestCollection = requestCollection,
Responder = _ =>
{
Expand Down Expand Up @@ -895,6 +915,7 @@ public SequencedResponseBuilder RespondsWithStream(Stream stream, string content
Scheme = scheme,
HostPattern = hostPattern,
CustomMatchers = customMatchers,
ForceTextualBody = forceTextualBody,
RequestCollection = requestCollection,
Responder = _ => new HttpResponseMessage(HttpStatusCode.OK)
{
Expand Down Expand Up @@ -980,6 +1001,7 @@ public SequencedResponseBuilder RespondsWith(Func<RequestInfo, CancellationToken
Scheme = scheme,
HostPattern = hostPattern,
CustomMatchers = customMatchers,
ForceTextualBody = forceTextualBody,
RequestCollection = requestCollection,
};

Expand Down
5 changes: 5 additions & 0 deletions SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,11 @@ mock.ForPost().WithPath("/api/data").WithBodyMatchingRegex(".*keyword.*").Respon

// Custom predicate (sync or async) — use .With() for custom body or URI checks
mock.ForPost().WithPath("/api/data").With(req => req.Body!.Contains("keyword")).RespondsWithStatus(HttpStatusCode.NoContent);

// Body matching (WithBody, WithBodyMatchingJson, etc.) only works when the Content-Type is recognized as
// textual (text/*, multipart/*, application/json, application/xml, and similar). For an unrecognized or
// binary-looking Content-Type that is actually text, opt in with TreatBodyAsTextual():
mock.ForPost().WithPath("/api/data").WithBody("*keyword*").TreatBodyAsTextual().RespondsWithStatus(HttpStatusCode.NoContent);
```

## Header Matching
Expand Down
12 changes: 12 additions & 0 deletions website/docs/advanced.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,18 @@ mock.ForPost()
.RespondsWithStatus(HttpStatusCode.NoContent);
```

### Forcing a Body to be Treated as Textual

Body matchers (`WithBody`, `WithBodyMatchingJson`, `WithBodyMatchingRegex`, and form-field matching) only work when the request's Content-Type is recognized as textual (`text/*`, `multipart/*`, `application/json`, `application/xml`, and similar). If your request uses a Content-Type Mockly doesn't recognize as textual — but the body is actually text — opt in with `TreatBodyAsTextual()`:

```csharp
mock.ForPost()
.WithPath("/api/test")
.WithBody("*something*")
.TreatBodyAsTextual()
.RespondsWithStatus(HttpStatusCode.NoContent);
```

## Request Body Prefetching

By default, Mockly prefetches the request body for matchers. You can disable this to defer reading content inside your predicate:
Expand Down
Loading