diff --git a/Mockly.ApiVerificationTests/ApprovedApi/net472.verified.txt b/Mockly.ApiVerificationTests/ApprovedApi/net472.verified.txt index be7a9a1..d696480 100644 --- a/Mockly.ApiVerificationTests/ApprovedApi/net472.verified.txt +++ b/Mockly.ApiVerificationTests/ApprovedApi/net472.verified.txt @@ -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? 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> matcher, [System.Runtime.CompilerServices.CallerArgumentExpression("matcher")] string? matcherText = null) { } public Mockly.RequestMockBuilder With(System.Func matcher, [System.Runtime.CompilerServices.CallerArgumentExpression("matcher")] string? matcherText = null) { } diff --git a/Mockly.ApiVerificationTests/ApprovedApi/net8.0.verified.txt b/Mockly.ApiVerificationTests/ApprovedApi/net8.0.verified.txt index cfe3d22..371e8ec 100644 --- a/Mockly.ApiVerificationTests/ApprovedApi/net8.0.verified.txt +++ b/Mockly.ApiVerificationTests/ApprovedApi/net8.0.verified.txt @@ -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? 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> matcher, [System.Runtime.CompilerServices.CallerArgumentExpression("matcher")] string? matcherText = null) { } public Mockly.RequestMockBuilder With(System.Func matcher, [System.Runtime.CompilerServices.CallerArgumentExpression("matcher")] string? matcherText = null) { } diff --git a/Mockly.Specs/HttpMockSpecs.cs b/Mockly.Specs/HttpMockSpecs.cs index f2340b9..214b2e9 100644 --- a/Mockly.Specs/HttpMockSpecs.cs +++ b/Mockly.Specs/HttpMockSpecs.cs @@ -880,6 +880,53 @@ public async Task Can_match_a_multipart_batch_body_against_a_wildcard_pattern() 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")); + 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(); + } + + [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")); + 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() { diff --git a/Mockly/HttpMock.cs b/Mockly/HttpMock.cs index fda3358..8cc2978 100644 --- a/Mockly/HttpMock.cs +++ b/Mockly/HttpMock.cs @@ -401,7 +401,11 @@ private async Task 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 }; } /// diff --git a/Mockly/RequestInfo.cs b/Mockly/RequestInfo.cs index 5cbad52..e104e12 100644 --- a/Mockly/RequestInfo.cs +++ b/Mockly/RequestInfo.cs @@ -15,15 +15,22 @@ public RequestInfo(HttpRequestMessage request, byte[]? rawBody) { this.request = request; RawBody = rawBody; - Body = DeserializeBodyIfTextual(rawBody); } + /// + /// When set, forces to be populated from regardless of whether + /// recognizes the Content-Type as textual. Set internally by + /// when a registered mock opted in via + /// . + /// + internal bool ForceTextualBody { get; init; } + /// /// Gets the URI of the HTTP request, representing the full address, including the scheme, host, path, and query string, if present. /// public Uri? Uri => request.RequestUri; - public string? Body { get; } + public string? Body => DeserializeBodyIfTextual(); /// /// The request body as raw bytes, if prefetched. @@ -134,17 +141,18 @@ public bool IsBodyLikelyTextual() } /// - /// 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 + /// is true. /// - 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() diff --git a/Mockly/RequestMock.cs b/Mockly/RequestMock.cs index 4d29e6f..29cc035 100644 --- a/Mockly/RequestMock.cs +++ b/Mockly/RequestMock.cs @@ -62,6 +62,12 @@ public class RequestMock /// internal IEnumerable CustomMatchers { get; init; } = []; + /// + /// 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 . + /// + internal bool ForceTextualBody { get; init; } + /// /// Gets or sets the responder used to produce a response for a matched request. /// diff --git a/Mockly/RequestMockBuilder.cs b/Mockly/RequestMockBuilder.cs index f432d0d..f7f1cb7 100644 --- a/Mockly/RequestMockBuilder.cs +++ b/Mockly/RequestMockBuilder.cs @@ -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) { @@ -317,6 +318,21 @@ public RequestMockBuilder WithBody(string wildcardPattern) $"body matches wildcard pattern \"{wildcardPattern}\""); } + /// + /// 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). + /// + /// + /// 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 gets populated and body-based + /// matchers such as can match against it. + /// + public RequestMockBuilder TreatBodyAsTextual() + { + forceTextualBody = true; + return this; + } + /// /// Configures the request mock to match requests that contain the specified header, regardless of its value. /// @@ -472,6 +488,7 @@ private SequencedResponseBuilder CreateResponse(Func { @@ -783,6 +801,7 @@ public SequencedResponseBuilder RespondsWithFile(string path, string? contentTyp Scheme = scheme, HostPattern = hostPattern, CustomMatchers = customMatchers, + ForceTextualBody = forceTextualBody, RequestCollection = requestCollection, Responder = _ => { @@ -833,6 +852,7 @@ public SequencedResponseBuilder RespondsWithBytes(byte[] content, string content Scheme = scheme, HostPattern = hostPattern, CustomMatchers = customMatchers, + ForceTextualBody = forceTextualBody, RequestCollection = requestCollection, Responder = _ => { @@ -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) { @@ -980,6 +1001,7 @@ public SequencedResponseBuilder RespondsWith(Func 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 diff --git a/website/docs/advanced.md b/website/docs/advanced.md index dac561e..badf4c7 100644 --- a/website/docs/advanced.md +++ b/website/docs/advanced.md @@ -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: