Skip to content

Commit 368b5f6

Browse files
fix: outgoing body capture stream preservation
fix: outgoing body capture stream preservation
2 parents 125c434 + 63667b3 commit 368b5f6

2 files changed

Lines changed: 313 additions & 11 deletions

File tree

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
using System.Net;
2+
using System.Net.Http.Headers;
3+
using System.Text;
4+
using DebugProbe.AspNetCore.Handlers;
5+
using DebugProbe.AspNetCore.Models;
6+
using DebugProbe.AspNetCore.Options;
7+
8+
namespace DebugProbe.AspNetCore.Tests.Handlers;
9+
10+
public class DebugProbeHttpClientHandlerBodyTests
11+
{
12+
// ---------------------------------------------------------------------------
13+
// Helpers
14+
// ---------------------------------------------------------------------------
15+
16+
/// <summary>
17+
/// Builds a handler wired to the given DebugEntry and options, with a stub
18+
/// inner handler that echoes <paramref name="responseContent"/> back.
19+
/// </summary>
20+
private static (HttpClient client, DebugEntry entry) BuildClient(
21+
DebugProbeOptions options,
22+
HttpContent? responseContent = null)
23+
{
24+
var entry = new DebugEntry();
25+
var context = new DefaultHttpContext();
26+
context.Items["DebugProbeEntry"] = entry;
27+
28+
var handler = new DebugProbeHttpClientHandler(
29+
new HttpContextAccessor { HttpContext = context },
30+
options)
31+
{
32+
InnerHandler = new StubHandler(_ =>
33+
new HttpResponseMessage(HttpStatusCode.OK)
34+
{
35+
Content = responseContent
36+
})
37+
};
38+
39+
return (new HttpClient(handler), entry);
40+
}
41+
42+
// ---------------------------------------------------------------------------
43+
// Test cases
44+
// ---------------------------------------------------------------------------
45+
46+
[Fact]
47+
public async Task Body_under_limit_is_captured_without_truncation_marker()
48+
{
49+
const int limitKb = 1; // 1 KB limit
50+
var bodyText = new string('A', 100); // 100 bytes — well under 1 KB
51+
52+
var options = new DebugProbeOptions { MaxBodyCaptureSizeKb = limitKb };
53+
var (client, entry) = BuildClient(options,
54+
responseContent: new StringContent(bodyText, Encoding.UTF8, "text/plain"));
55+
56+
await client.GetAsync("https://example.test/");
57+
58+
var outgoing = Assert.Single(entry.OutgoingRequests);
59+
Assert.DoesNotContain("[truncated]", outgoing.ResponseBody);
60+
Assert.Contains(bodyText, outgoing.ResponseBody);
61+
}
62+
63+
[Fact]
64+
public async Task Body_exactly_at_limit_is_captured_without_truncation_marker()
65+
{
66+
const int limitKb = 1;
67+
var bodyText = new string('B', 1024); // exactly 1 KB
68+
69+
var options = new DebugProbeOptions { MaxBodyCaptureSizeKb = limitKb };
70+
var (client, entry) = BuildClient(options,
71+
responseContent: new StringContent(bodyText, Encoding.UTF8, "text/plain"));
72+
73+
await client.GetAsync("https://example.test/");
74+
75+
var outgoing = Assert.Single(entry.OutgoingRequests);
76+
Assert.DoesNotContain("[truncated]", outgoing.ResponseBody);
77+
Assert.Contains(bodyText, outgoing.ResponseBody);
78+
}
79+
80+
[Fact]
81+
public async Task Body_over_limit_is_truncated_and_marker_is_appended()
82+
{
83+
const int limitKb = 1;
84+
var bodyText = new string('C', 2048); // 2 KB — double the limit
85+
86+
var options = new DebugProbeOptions { MaxBodyCaptureSizeKb = limitKb };
87+
var (client, entry) = BuildClient(options,
88+
responseContent: new StringContent(bodyText, Encoding.UTF8, "text/plain"));
89+
90+
await client.GetAsync("https://example.test/");
91+
92+
var outgoing = Assert.Single(entry.OutgoingRequests);
93+
Assert.EndsWith("[truncated]", outgoing.ResponseBody);
94+
// Captured prefix must be exactly the limit (1024 'C' chars)
95+
Assert.StartsWith(new string('C', 1024), outgoing.ResponseBody);
96+
}
97+
98+
[Fact]
99+
public async Task Capturing_streamed_response_body_does_not_consume_body_for_caller()
100+
{
101+
const int limitKb = 1;
102+
var bodyText = new string('D', 2048);
103+
var responseContent = new StreamContent(new NonSeekableMemoryStream(Encoding.UTF8.GetBytes(bodyText)));
104+
responseContent.Headers.ContentType = new MediaTypeHeaderValue("text/plain")
105+
{
106+
CharSet = Encoding.UTF8.WebName
107+
};
108+
109+
var options = new DebugProbeOptions { MaxBodyCaptureSizeKb = limitKb };
110+
var (client, entry) = BuildClient(options, responseContent);
111+
112+
using var response = await client.GetAsync("https://example.test/");
113+
114+
Assert.Equal(bodyText, await response.Content.ReadAsStringAsync());
115+
116+
var outgoing = Assert.Single(entry.OutgoingRequests);
117+
Assert.EndsWith("[truncated]", outgoing.ResponseBody);
118+
}
119+
120+
[Fact]
121+
public async Task Captured_body_uses_content_type_charset()
122+
{
123+
const string bodyText = "Hello \u0100";
124+
var responseContent = new StringContent(bodyText, Encoding.Unicode, "text/plain");
125+
126+
var options = new DebugProbeOptions { MaxBodyCaptureSizeKb = 1 };
127+
var (client, entry) = BuildClient(options, responseContent);
128+
129+
using var response = await client.GetAsync("https://example.test/");
130+
131+
Assert.Equal(bodyText, await response.Content.ReadAsStringAsync());
132+
133+
var outgoing = Assert.Single(entry.OutgoingRequests);
134+
Assert.Contains(bodyText, outgoing.ResponseBody);
135+
}
136+
137+
[Fact]
138+
public async Task Null_content_returns_empty_string()
139+
{
140+
var options = new DebugProbeOptions();
141+
var (client, entry) = BuildClient(options, responseContent: null);
142+
143+
await client.GetAsync("https://example.test/");
144+
145+
var outgoing = Assert.Single(entry.OutgoingRequests);
146+
Assert.Equal(string.Empty, outgoing.ResponseBody);
147+
}
148+
149+
[Fact]
150+
public async Task Non_text_content_returns_empty_string()
151+
{
152+
var binaryContent = new ByteArrayContent([0x89, 0x50, 0x4E, 0x47]); // PNG header bytes
153+
binaryContent.Headers.ContentType =
154+
new System.Net.Http.Headers.MediaTypeHeaderValue("image/png");
155+
156+
var options = new DebugProbeOptions();
157+
var (client, entry) = BuildClient(options, responseContent: binaryContent);
158+
159+
await client.GetAsync("https://example.test/");
160+
161+
var outgoing = Assert.Single(entry.OutgoingRequests);
162+
Assert.Equal(string.Empty, outgoing.ResponseBody);
163+
}
164+
165+
// ---------------------------------------------------------------------------
166+
// Stub inner handler
167+
// ---------------------------------------------------------------------------
168+
169+
private sealed class StubHandler(Func<HttpRequestMessage, HttpResponseMessage> send) : HttpMessageHandler
170+
{
171+
protected override Task<HttpResponseMessage> SendAsync(
172+
HttpRequestMessage request, CancellationToken cancellationToken)
173+
=> Task.FromResult(send(request));
174+
}
175+
176+
private sealed class NonSeekableMemoryStream(byte[] buffer) : MemoryStream(buffer)
177+
{
178+
public override bool CanSeek => false;
179+
}
180+
}

DebugProbe.AspNetCore/Handlers/DebugProbeHttpClientHandler.cs

Lines changed: 133 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
using System.Diagnostics;
1+
using System.Diagnostics;
2+
using System.Net;
3+
using System.Text;
24
using DebugProbe.AspNetCore.Internal.Utils;
35
using DebugProbe.AspNetCore.Models;
46
using DebugProbe.AspNetCore.Options;
@@ -33,13 +35,13 @@ protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage
3335
{
3436
var response = await base.SendAsync(request, cancellationToken);
3537

36-
await CaptureRequest(request, response, null, started.ElapsedMilliseconds);
38+
await CaptureRequest(request, response, null, started.ElapsedMilliseconds, cancellationToken);
3739

3840
return response;
3941
}
4042
catch (Exception ex)
4143
{
42-
await CaptureRequest(request, null, ex, started.ElapsedMilliseconds);
44+
await CaptureRequest(request, null, ex, started.ElapsedMilliseconds, cancellationToken);
4345

4446
throw;
4547
}
@@ -48,7 +50,12 @@ protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage
4850
/// <summary>
4951
/// Captures outgoing request details and stores them in the active DebugProbe entry.
5052
/// </summary>
51-
private async Task CaptureRequest(HttpRequestMessage request, HttpResponseMessage? response, Exception? exception, long durationMs)
53+
private async Task CaptureRequest(
54+
HttpRequestMessage request,
55+
HttpResponseMessage? response,
56+
Exception? exception,
57+
long durationMs,
58+
CancellationToken cancellationToken)
5259
{
5360
if (!TryGetActiveEntry(out var entry))
5461
{
@@ -76,9 +83,9 @@ private async Task CaptureRequest(HttpRequestMessage request, HttpResponseMessag
7683
ResponseHeaders = response != null ? response.Headers.ToDictionary(x => x.Key, x => RedactionUtils.RedactHeader(x.Key, string.Join(", ", x.Value), _options)) : []
7784
};
7885

79-
outgoing.RequestBody = await CaptureBodyAsync(request.Content);
86+
outgoing.RequestBody = (await CaptureBodyAsync(request.Content, cancellationToken)).Body;
8087

81-
outgoing.ResponseBody = await CaptureBodyAsync(response?.Content);
88+
outgoing.ResponseBody = await CaptureResponseBodyAsync(response, cancellationToken);
8289

8390
entry.OutgoingRequests.Add(outgoing);
8491
}
@@ -100,18 +107,133 @@ private bool TryGetActiveEntry(out DebugEntry entry)
100107
return true;
101108
}
102109

103-
private async Task<string> CaptureBodyAsync(HttpContent? content)
110+
private async Task<string> CaptureResponseBodyAsync(HttpResponseMessage? response, CancellationToken cancellationToken)
111+
{
112+
var content = response?.Content;
113+
if (content == null)
114+
{
115+
return string.Empty;
116+
}
117+
118+
var result = await CaptureBodyAsync(content, cancellationToken);
119+
120+
if (result.BytesRead.Length > 0)
121+
{
122+
response!.Content = new PrefixReplayHttpContent(content, result.Stream, result.BytesRead);
123+
}
124+
125+
return result.Body;
126+
}
127+
128+
private async Task<BodyCaptureResult> CaptureBodyAsync(HttpContent? content, CancellationToken cancellationToken)
104129
{
105130
if (content == null ||
106131
!HttpContentUtils.IsTextContent(content.Headers.ContentType?.MediaType))
107132
{
108133
return string.Empty;
109134
}
110135

111-
var body = await content.ReadAsStringAsync();
136+
if (_options.MaxBodyCaptureSizeBytes <= 0)
137+
return string.Empty;
138+
139+
var limit = _options.MaxBodyCaptureSizeBytes;
140+
var buffer = new byte[limit + 1];
141+
var totalRead = 0;
142+
143+
var stream = await content.ReadAsStreamAsync(cancellationToken);
144+
145+
int bytesRead;
146+
while (totalRead < buffer.Length &&
147+
(bytesRead = await stream.ReadAsync(buffer.AsMemory(totalRead, buffer.Length - totalRead), cancellationToken)) > 0)
148+
{
149+
totalRead += bytesRead;
150+
}
151+
152+
var truncated = totalRead > limit;
153+
var encoding = GetEncoding(content);
154+
var body = encoding.GetString(buffer, 0, Math.Min(totalRead, limit));
155+
156+
if (truncated)
157+
{
158+
body += "[truncated]";
159+
}
160+
161+
return new BodyCaptureResult(
162+
JsonUtils.Format(RedactionUtils.RedactJsonFields(body, _options)),
163+
stream,
164+
buffer[..totalRead]);
165+
}
112166

113-
return JsonUtils.Format(RedactionUtils.RedactJsonFields(
114-
HttpContentUtils.Trim(body, _options.MaxBodyCaptureSizeBytes),
115-
_options));
167+
private static Encoding GetEncoding(HttpContent content)
168+
{
169+
var charset = content.Headers.ContentType?.CharSet;
170+
if (string.IsNullOrWhiteSpace(charset))
171+
{
172+
return Encoding.UTF8;
173+
}
174+
175+
try
176+
{
177+
return Encoding.GetEncoding(charset.Trim('"'));
178+
}
179+
catch
180+
{
181+
return Encoding.UTF8;
182+
}
183+
}
184+
185+
private readonly record struct BodyCaptureResult(string Body, Stream Stream, byte[] BytesRead)
186+
{
187+
public static implicit operator BodyCaptureResult(string body) => new(body, Stream.Null, []);
188+
}
189+
190+
private sealed class PrefixReplayHttpContent : HttpContent
191+
{
192+
private readonly HttpContent _innerContent;
193+
194+
private readonly Stream _remainingStream;
195+
196+
private readonly byte[] _prefix;
197+
198+
public PrefixReplayHttpContent(HttpContent innerContent, Stream remainingStream, byte[] prefix)
199+
{
200+
_innerContent = innerContent;
201+
_remainingStream = remainingStream;
202+
_prefix = prefix;
203+
204+
foreach (var header in innerContent.Headers)
205+
{
206+
Headers.TryAddWithoutValidation(header.Key, header.Value);
207+
}
208+
}
209+
210+
protected override async Task SerializeToStreamAsync(Stream stream, TransportContext? context)
211+
{
212+
await stream.WriteAsync(_prefix);
213+
await _remainingStream.CopyToAsync(stream);
214+
}
215+
216+
protected override bool TryComputeLength(out long length)
217+
{
218+
if (_innerContent.Headers.ContentLength is { } contentLength)
219+
{
220+
length = contentLength;
221+
return true;
222+
}
223+
224+
length = 0;
225+
return false;
226+
}
227+
228+
protected override void Dispose(bool disposing)
229+
{
230+
if (disposing)
231+
{
232+
_remainingStream.Dispose();
233+
_innerContent.Dispose();
234+
}
235+
236+
base.Dispose(disposing);
237+
}
116238
}
117239
}

0 commit comments

Comments
 (0)