Skip to content

Commit 89ac9d9

Browse files
Add TreatBodyAsTextual() to force textual body decoding (#163)
* Add TreatBodyAsTextual() to force textual body decoding Body-based matchers (WithBody, WithBodyMatchingRegex, WithBodyMatchingJson, form-field matching) only match against RequestInfo.Body, which stays null when the request's Content-Type isn't recognized as textual. There's no way for callers to opt in when they know the body is actually text but uses an unrecognized or binary-looking media type. RequestMockBuilder.TreatBodyAsTextual() lets a mock opt into decoding the body as text regardless of Content-Type. Mirrors the existing PrefetchBody pattern: the public toggle is a simple flag, and the mechanics are localized in HttpMock.BuildRequestInfo, which now forces RequestInfo to decode the body as text when any registered mock has opted in. No changes were needed to the individual body-matching methods, since they already read RequestInfo.Body. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Document TreatBodyAsTextual() in SKILL.md and advanced docs Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent 4fe054f commit 89ac9d9

9 files changed

Lines changed: 113 additions & 7 deletions

File tree

Mockly.ApiVerificationTests/ApprovedApi/net472.verified.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,7 @@ namespace Mockly
134134
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) { }
135135
public Mockly.SequencedResponseBuilder RespondsWithStatus(System.Net.HttpStatusCode statusCode) { }
136136
public Mockly.SequencedResponseBuilder RespondsWithStream(System.IO.Stream stream, string contentType) { }
137+
public Mockly.RequestMockBuilder TreatBodyAsTextual() { }
137138
public Mockly.RequestMockBuilder Using(System.Text.Json.JsonSerializerOptions options) { }
138139
public Mockly.RequestMockBuilder With(System.Func<Mockly.RequestInfo, System.Threading.Tasks.Task<bool>> matcher, [System.Runtime.CompilerServices.CallerArgumentExpression("matcher")] string? matcherText = null) { }
139140
public Mockly.RequestMockBuilder With(System.Func<Mockly.RequestInfo, bool> matcher, [System.Runtime.CompilerServices.CallerArgumentExpression("matcher")] string? matcherText = null) { }

Mockly.ApiVerificationTests/ApprovedApi/net8.0.verified.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@ namespace Mockly
137137
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) { }
138138
public Mockly.SequencedResponseBuilder RespondsWithStatus(System.Net.HttpStatusCode statusCode) { }
139139
public Mockly.SequencedResponseBuilder RespondsWithStream(System.IO.Stream stream, string contentType) { }
140+
public Mockly.RequestMockBuilder TreatBodyAsTextual() { }
140141
public Mockly.RequestMockBuilder Using(System.Text.Json.JsonSerializerOptions options) { }
141142
public Mockly.RequestMockBuilder With(System.Func<Mockly.RequestInfo, System.Threading.Tasks.Task<bool>> matcher, [System.Runtime.CompilerServices.CallerArgumentExpression("matcher")] string? matcherText = null) { }
142143
public Mockly.RequestMockBuilder With(System.Func<Mockly.RequestInfo, bool> matcher, [System.Runtime.CompilerServices.CallerArgumentExpression("matcher")] string? matcherText = null) { }

Mockly.Specs/HttpMockSpecs.cs

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -880,6 +880,53 @@ public async Task Can_match_a_multipart_batch_body_against_a_wildcard_pattern()
880880
response.StatusCode.Should().Be(HttpStatusCode.NoContent);
881881
}
882882

883+
[Fact]
884+
public async Task Cannot_match_body_with_an_unrecognized_content_type_by_default()
885+
{
886+
// Arrange
887+
var mock = new HttpMock();
888+
889+
mock.ForPost()
890+
.WithPath("/api/test")
891+
.WithBody("*something*")
892+
.RespondsWithStatus(HttpStatusCode.NoContent);
893+
894+
var client = mock.GetClient();
895+
896+
var content = new ByteArrayContent(Encoding.UTF8.GetBytes("a body with something in it"));
897+
content.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream");
898+
899+
// Act
900+
var action = async () => await client.PostAsync("https://localhost/api/test", content);
901+
902+
// Assert
903+
await action.Should().ThrowAsync<UnexpectedRequestException>();
904+
}
905+
906+
[Fact]
907+
public async Task Can_force_a_body_with_an_unrecognized_content_type_to_be_treated_as_textual()
908+
{
909+
// Arrange
910+
var mock = new HttpMock();
911+
912+
mock.ForPost()
913+
.WithPath("/api/test")
914+
.WithBody("*something*")
915+
.TreatBodyAsTextual()
916+
.RespondsWithStatus(HttpStatusCode.NoContent);
917+
918+
var client = mock.GetClient();
919+
920+
var content = new ByteArrayContent(Encoding.UTF8.GetBytes("a body with something in it"));
921+
content.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream");
922+
923+
// Act
924+
var response = await client.PostAsync("https://localhost/api/test", content);
925+
926+
// Assert
927+
response.StatusCode.Should().Be(HttpStatusCode.NoContent);
928+
}
929+
883930
[Fact]
884931
public async Task Can_match_the_body_against_a_json_string_ignoring_layout()
885932
{

Mockly/HttpMock.cs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -401,7 +401,11 @@ private async Task<RequestInfo> BuildRequestInfo(HttpRequestMessage httpRequest)
401401
rawBody = await httpRequest.Content.ReadAsByteArrayAsync();
402402
}
403403

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

407411
/// <summary>

Mockly/RequestInfo.cs

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,15 +15,22 @@ public RequestInfo(HttpRequestMessage request, byte[]? rawBody)
1515
{
1616
this.request = request;
1717
RawBody = rawBody;
18-
Body = DeserializeBodyIfTextual(rawBody);
1918
}
2019

20+
/// <summary>
21+
/// When set, forces <see cref="Body"/> to be populated from <see cref="RawBody"/> regardless of whether
22+
/// <see cref="IsBodyLikelyTextual"/> recognizes the Content-Type as textual. Set internally by
23+
/// <see cref="HttpMock"/> when a registered mock opted in via
24+
/// <see cref="RequestMockBuilder.TreatBodyAsTextual"/>.
25+
/// </summary>
26+
internal bool ForceTextualBody { get; init; }
27+
2128
/// <summary>
2229
/// Gets the URI of the HTTP request, representing the full address, including the scheme, host, path, and query string, if present.
2330
/// </summary>
2431
public Uri? Uri => request.RequestUri;
2532

26-
public string? Body { get; }
33+
public string? Body => DeserializeBodyIfTextual();
2734

2835
/// <summary>
2936
/// The request body as raw bytes, if prefetched.
@@ -134,17 +141,18 @@ public bool IsBodyLikelyTextual()
134141
}
135142

136143
/// <summary>
137-
/// Deserializes the provided raw body byte array into a textual representation if it is likely to be textual.
144+
/// Deserializes the raw body byte array into a textual representation if it is likely to be textual, or if
145+
/// <see cref="ForceTextualBody"/> is <c>true</c>.
138146
/// </summary>
139-
private string? DeserializeBodyIfTextual(byte[]? rawBody)
147+
private string? DeserializeBodyIfTextual()
140148
{
141-
if (rawBody is null || rawBody.Length == 0 || !IsBodyLikelyTextual())
149+
if (RawBody is null || RawBody.Length == 0 || (!IsBodyLikelyTextual() && !ForceTextualBody))
142150
{
143151
return null;
144152
}
145153

146154
Encoding encoding = GetEncoding() ?? Encoding.UTF8;
147-
return encoding.GetString(rawBody);
155+
return encoding.GetString(RawBody);
148156
}
149157

150158
private Encoding? GetEncoding()

Mockly/RequestMock.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,12 @@ public class RequestMock
6262
/// </summary>
6363
internal IEnumerable<Matcher> CustomMatchers { get; init; } = [];
6464

65+
/// <summary>
66+
/// Gets whether this mock forces the captured request body to be treated as textual, even when its
67+
/// Content-Type isn't recognized as textual. See <see cref="RequestMockBuilder.TreatBodyAsTextual"/>.
68+
/// </summary>
69+
internal bool ForceTextualBody { get; init; }
70+
6571
/// <summary>
6672
/// Gets or sets the responder used to produce a response for a matched request.
6773
/// </summary>

Mockly/RequestMockBuilder.cs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ public class RequestMockBuilder
2929
private string? hostPattern = "localhost";
3030
private RequestCollection? requestCollection;
3131
private JsonSerializerOptions? jsonSerializerOptions;
32+
private bool forceTextualBody;
3233

3334
internal RequestMockBuilder(HttpMock mockBuilder, HttpMethod method)
3435
{
@@ -317,6 +318,21 @@ public RequestMockBuilder WithBody(string wildcardPattern)
317318
$"body matches wildcard pattern \"{wildcardPattern}\"");
318319
}
319320

321+
/// <summary>
322+
/// Forces the captured request body to be treated as textual for this mock, even when its Content-Type
323+
/// isn't recognized as textual (e.g. an unrecognized or binary-looking media type).
324+
/// </summary>
325+
/// <remarks>
326+
/// Use this as an escape hatch when the request body is actually text but uses a Content-Type that Mockly
327+
/// doesn't recognize as textual, so that <see cref="RequestInfo.Body"/> gets populated and body-based
328+
/// matchers such as <see cref="WithBody(string)"/> can match against it.
329+
/// </remarks>
330+
public RequestMockBuilder TreatBodyAsTextual()
331+
{
332+
forceTextualBody = true;
333+
return this;
334+
}
335+
320336
/// <summary>
321337
/// Configures the request mock to match requests that contain the specified header, regardless of its value.
322338
/// </summary>
@@ -472,6 +488,7 @@ private SequencedResponseBuilder CreateResponse(Func<RequestInfo, HttpResponseMe
472488
Scheme = scheme,
473489
HostPattern = hostPattern,
474490
CustomMatchers = customMatchers,
491+
ForceTextualBody = forceTextualBody,
475492
RequestCollection = requestCollection,
476493
Responder = responder
477494
};
@@ -593,6 +610,7 @@ public SequencedResponseBuilder RespondsWithProblemDetails(
593610
Scheme = scheme,
594611
HostPattern = hostPattern,
595612
CustomMatchers = customMatchers,
613+
ForceTextualBody = forceTextualBody,
596614
RequestCollection = requestCollection,
597615
Responder = _ =>
598616
{
@@ -783,6 +801,7 @@ public SequencedResponseBuilder RespondsWithFile(string path, string? contentTyp
783801
Scheme = scheme,
784802
HostPattern = hostPattern,
785803
CustomMatchers = customMatchers,
804+
ForceTextualBody = forceTextualBody,
786805
RequestCollection = requestCollection,
787806
Responder = _ =>
788807
{
@@ -833,6 +852,7 @@ public SequencedResponseBuilder RespondsWithBytes(byte[] content, string content
833852
Scheme = scheme,
834853
HostPattern = hostPattern,
835854
CustomMatchers = customMatchers,
855+
ForceTextualBody = forceTextualBody,
836856
RequestCollection = requestCollection,
837857
Responder = _ =>
838858
{
@@ -895,6 +915,7 @@ public SequencedResponseBuilder RespondsWithStream(Stream stream, string content
895915
Scheme = scheme,
896916
HostPattern = hostPattern,
897917
CustomMatchers = customMatchers,
918+
ForceTextualBody = forceTextualBody,
898919
RequestCollection = requestCollection,
899920
Responder = _ => new HttpResponseMessage(HttpStatusCode.OK)
900921
{
@@ -980,6 +1001,7 @@ public SequencedResponseBuilder RespondsWith(Func<RequestInfo, CancellationToken
9801001
Scheme = scheme,
9811002
HostPattern = hostPattern,
9821003
CustomMatchers = customMatchers,
1004+
ForceTextualBody = forceTextualBody,
9831005
RequestCollection = requestCollection,
9841006
};
9851007

SKILL.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,11 @@ mock.ForPost().WithPath("/api/data").WithBodyMatchingRegex(".*keyword.*").Respon
6868

6969
// Custom predicate (sync or async) — use .With() for custom body or URI checks
7070
mock.ForPost().WithPath("/api/data").With(req => req.Body!.Contains("keyword")).RespondsWithStatus(HttpStatusCode.NoContent);
71+
72+
// Body matching (WithBody, WithBodyMatchingJson, etc.) only works when the Content-Type is recognized as
73+
// textual (text/*, multipart/*, application/json, application/xml, and similar). For an unrecognized or
74+
// binary-looking Content-Type that is actually text, opt in with TreatBodyAsTextual():
75+
mock.ForPost().WithPath("/api/data").WithBody("*keyword*").TreatBodyAsTextual().RespondsWithStatus(HttpStatusCode.NoContent);
7176
```
7277

7378
## Header Matching

website/docs/advanced.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,18 @@ mock.ForPost()
114114
.RespondsWithStatus(HttpStatusCode.NoContent);
115115
```
116116

117+
### Forcing a Body to be Treated as Textual
118+
119+
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()`:
120+
121+
```csharp
122+
mock.ForPost()
123+
.WithPath("/api/test")
124+
.WithBody("*something*")
125+
.TreatBodyAsTextual()
126+
.RespondsWithStatus(HttpStatusCode.NoContent);
127+
```
128+
117129
## Request Body Prefetching
118130

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

0 commit comments

Comments
 (0)