diff --git a/src/HttpWebRequestWrapper.HttpClient/Extensions/HttpClientHandlerExtensions.cs b/src/HttpWebRequestWrapper.HttpClient/Extensions/HttpClientHandlerExtensions.cs index 49edd95..d18f8fa 100644 --- a/src/HttpWebRequestWrapper.HttpClient/Extensions/HttpClientHandlerExtensions.cs +++ b/src/HttpWebRequestWrapper.HttpClient/Extensions/HttpClientHandlerExtensions.cs @@ -2,7 +2,6 @@ using System.Net; using System.Net.Http; using System.Reflection; -using System.Threading.Tasks; namespace HttpWebRequestWrapper.HttpClient.Extensions { @@ -28,6 +27,10 @@ internal static class HttpClientHandlerExtensions typeof(HttpClientHandler) .GetMethod("SetContentHeaders", BindingFlags.NonPublic | BindingFlags.Static); + private static readonly MethodInfo _initializeWebRequest = + typeof(HttpClientHandler) + .GetMethod("InitializeWebRequest", BindingFlags.NonPublic | BindingFlags.Instance); + private static readonly FieldInfo _getRequestStreamCallback = typeof(HttpClientHandler) .GetField("getRequestStreamCallback", BindingFlags.NonPublic | BindingFlags.Instance); @@ -59,6 +62,8 @@ public static void PrepareWebRequest( _setRequestHeaders.Invoke(null, new object[] { webRequest, requestMessage }); // HttpClientHandler.SetContentHeaders(HttpWebRequest webRequest, HttpRequestMessage request); _setContentHeaders.Invoke(null, new object[] { webRequest, requestMessage }); + // HttpClientHandler.InitializeWebRequest(HttpRequestMessage request, HttpWebRequest webRequest); + _initializeWebRequest.Invoke(httpClientHandler, new object[] { requestMessage, webRequest }); } public static void SetGetRequestStreamCallback( diff --git a/src/HttpWebRequestWrapper.HttpClient/Extensions/HttpWebRequestRefelctionExtensions.cs b/src/HttpWebRequestWrapper.HttpClient/Extensions/HttpWebRequestRefelctionExtensions.cs index 0423b62..4cc71b3 100644 --- a/src/HttpWebRequestWrapper.HttpClient/Extensions/HttpWebRequestRefelctionExtensions.cs +++ b/src/HttpWebRequestWrapper.HttpClient/Extensions/HttpWebRequestRefelctionExtensions.cs @@ -12,9 +12,9 @@ internal static class HttpWebRequestRefelctionExtensions public static void SetReturnResponseOnFailureStatusCode( this HttpWebRequest webRequest, - bool @value) + bool value) { - _returnResponseOnFailureStatusCodeField.SetValue(webRequest, @value); + _returnResponseOnFailureStatusCodeField.SetValue(webRequest, value); } } } \ No newline at end of file diff --git a/src/HttpWebRequestWrapper.HttpClient/Extensions/TaskSchedulerReflectionExtensons.cs b/src/HttpWebRequestWrapper.HttpClient/Extensions/TaskSchedulerReflectionExtensons.cs index 469be4a..7cc6ad2 100644 --- a/src/HttpWebRequestWrapper.HttpClient/Extensions/TaskSchedulerReflectionExtensons.cs +++ b/src/HttpWebRequestWrapper.HttpClient/Extensions/TaskSchedulerReflectionExtensons.cs @@ -26,6 +26,12 @@ internal static class TaskSchedulerReflectionExtensons "QueueTask", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + private static readonly MethodInfo _tryDequeueMethod = + typeof(TaskScheduler) + .GetMethod( + "TryDequeue", + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + private static readonly MethodInfo _tryExecuteTaskInlineMethod = typeof(TaskScheduler) .GetMethod( @@ -63,6 +69,21 @@ public static void QueueTask(this TaskScheduler scheduler, Task task) }); } + /// + /// Uses reflection to execute the protected method + /// . + /// + public static bool TryDequeue(this TaskScheduler scheduler, Task task) + { + return (bool) + _tryDequeueMethod.Invoke( + scheduler, + new object[] + { + task + }); + } + /// /// Uses reflection to execute the protected method /// . diff --git a/src/HttpWebRequestWrapper.HttpClient/HttpClientHandlerStartRequestTaskVisitor.cs b/src/HttpWebRequestWrapper.HttpClient/HttpClientHandlerStartRequestTaskVisitor.cs index 16397a1..5ad16e5 100644 --- a/src/HttpWebRequestWrapper.HttpClient/HttpClientHandlerStartRequestTaskVisitor.cs +++ b/src/HttpWebRequestWrapper.HttpClient/HttpClientHandlerStartRequestTaskVisitor.cs @@ -100,7 +100,7 @@ private void CustomGetRequestStreamCallback(IAsyncResult ar, HttpClientHandler h var httpWebRequest = requestStateWrapper.GetHttpWebRequest(); // get a copy of the request streams - var requestStream = httpWebRequest.GetRequestStream(); + var requestStream = httpWebRequest.EndGetRequestStream(ar); // copy the request message content to the request stream requestMessage.Content.CopyToAsync(requestStream).Wait(); diff --git a/src/HttpWebRequestWrapper.HttpClient/Threading/Tasks/TaskSchedulerProxy.cs b/src/HttpWebRequestWrapper.HttpClient/Threading/Tasks/TaskSchedulerProxy.cs index 01803d4..7191ff9 100644 --- a/src/HttpWebRequestWrapper.HttpClient/Threading/Tasks/TaskSchedulerProxy.cs +++ b/src/HttpWebRequestWrapper.HttpClient/Threading/Tasks/TaskSchedulerProxy.cs @@ -36,6 +36,11 @@ protected override void QueueTask(Task task) _inner.QueueTask(task); } + protected override bool TryDequeue(Task task) + { + return _inner.TryDequeue(task); + } + protected override bool TryExecuteTaskInline(Task task, bool taskWasPreviouslyQueued) { return _inner.TryExecuteTaskInline(task, taskWasPreviouslyQueued); diff --git a/src/HttpWebRequestWrapper.Tests/HttpClientTests.cs b/src/HttpWebRequestWrapper.Tests/HttpClientTests.cs index 0407f1d..2a40c83 100644 --- a/src/HttpWebRequestWrapper.Tests/HttpClientTests.cs +++ b/src/HttpWebRequestWrapper.Tests/HttpClientTests.cs @@ -1,9 +1,13 @@ using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Compression; using System.Net; using System.Net.Http; using System.Text; using System.Threading.Tasks; using HttpWebRequestWrapper.HttpClient; +using HttpWebRequestWrapper.Recording; using Should; using Xunit; @@ -46,7 +50,7 @@ public async Task CanRecord() recordingSession.RecordedRequests[0].Url.ShouldEqual(url); recordingSession.RecordedRequests[0].ResponseStatusCode.ShouldEqual(HttpStatusCode.OK); - recordingSession.RecordedRequests[0].ResponseBody.ShouldContain("(req => + { + if (req.HttpWebRequest.RequestUri == requestUrl) + { + return req.HttpWebResponseCreator.Create(responseBody); + } + + throw new Exception("Couldn't match request"); + }); + + string response; + + // ACT + using (new HttpClientAndRequestWrapperSession(new HttpWebRequestWrapperInterceptorCreator(responseCreator))) + { + var httpClient = new System.Net.Http.HttpClient(new WebRequestHandler()); + response = await httpClient.GetStringAsync(requestUrl); + } + + // ASSERT + response.ShouldEqual(responseBody); } [Fact] - public void CanInterceptWhenHttpClientSetsBaseAddress() + public async Task CanInterceptWhenHttpClientSetsBaseAddress() { + // ARRANGE + var requestBaseUrl = new Uri("http://fakesite.fake"); + var requestRelativeUrl = "/2"; + var requestFullUrl = new Uri(requestBaseUrl, requestRelativeUrl); + var responseBody = "web request testing"; + var responseCreator = new Func(req => + { + if (req.HttpWebRequest.RequestUri == requestFullUrl) + { + return req.HttpWebResponseCreator.Create(responseBody); + } + + throw new Exception("Couldn't match request"); + }); + + string response; + + // ACT + using (new HttpClientAndRequestWrapperSession(new HttpWebRequestWrapperInterceptorCreator(responseCreator))) + { + var httpClient = new System.Net.Http.HttpClient() + { + BaseAddress = requestBaseUrl + }; + + response = await httpClient.GetStringAsync(requestRelativeUrl); + } + + // ASSERT + response.ShouldEqual(responseBody); } + [Fact] public async Task CanInterceptCustomRequestMessage() { @@ -162,12 +252,12 @@ public async Task CanInterceptCustomRequestMessage() { if (req.HttpWebRequest.RequestUri == requestUrl && req.HttpWebRequest.Method == "POST" && - req.RequestPayload == requestBody) + req.RequestPayload.SerializedStream == requestBody) { return req.HttpWebResponseCreator.Create(responseBody); } - throw new Exception("Coulnd't match request"); + throw new Exception("Couldn't match request"); }); var requestMessage = new HttpRequestMessage(HttpMethod.Post, requestUrl) @@ -192,17 +282,6 @@ public async Task CanInterceptCustomRequestMessage() (await response.Content.ReadAsStringAsync()).ShouldEqual(responseBody); } - - // TODO - can intercept WebRequestHandler (inherits from HttpClientHandler) - // TODO - cna intercept when HttpClient has BaseAddress set - // TODO - test when using custom request message - // TODO - test when using Send with HttpCompletionOption - // TODO - can record post - // TODO - can record binary response stream - // TODO - can record post request payload - // TODO - can record binary request payload - // TODO - can match on binary request payload - [Fact(Timeout = 3000)] public async Task CanSupportMultipleConcurrentHttpClients() { @@ -249,6 +328,91 @@ public async Task CanSupportMultipleConcurrentHttpClients() } } + /// + /// https://github.com/ppittle/HttpWebRequestWrapper/issues/21 + /// found that after 2 successful intercepted requests sent via + /// , + /// a 3rd call would never return. + /// + /// This test is *not* able to completly reproduce the bad behavior. + /// However, the solution was to add + /// an override for + /// . + /// Adding the override causes a 10x performance increase in this test, so it's good + /// to have, but it means this test is a bit flimsy - it relies on a Timeout to + /// determine failure, so it can get a false positive/negative based on the + /// execution environment. But not sure how to make it better at this time. + /// + /// + [Fact(Timeout = 2000)] + public async Task CanInterceptMultipleSequentialPosts() + { + // ARRANGE + var numberOfSequentialRequests = 20; + + var recordedRequest = new RecordedRequest + { + Method = "POST", + Url = "http://fakeSite.fake/", + RequestPayload = new RecordedStream + { + SerializedStream = "Test Request" + }, + ResponseStatusCode = HttpStatusCode.OK, + ResponseBody = new RecordedStream + { + SerializedStream = "Test Response", + // improtant - force gzip so a compression stream gets plumbed + // through the http client as that changes behavior + IsGzippedCompressed = true + } + }; + + var requestBuilder = new RecordingSessionInterceptorRequestBuilder( + new RecordingSession + { + RecordedRequests = new List {recordedRequest} + }) + { + MatchingAlgorithm = (intercpeted, recorded) => + string.Equals( + intercpeted.HttpWebRequest.RequestUri.ToString(), + recorded.Url, + StringComparison.OrdinalIgnoreCase) + }; + + // ACT + using (new HttpClientAndRequestWrapperSession(new HttpWebRequestWrapperInterceptorCreator(requestBuilder))) + { + + for (var i = 0; i < numberOfSequentialRequests; i++) + { + var httpClient = new System.Net.Http.HttpClient(new WebRequestHandler()); + + var message = new HttpRequestMessage(HttpMethod.Post, recordedRequest.Url) + { + Content = new StringContent(recordedRequest.RequestPayload.ToString()) + }; + + var response = await httpClient.SendAsync(message); + + // decompress stream + var responseStream = await response.Content.ReadAsStreamAsync(); + + using (var zip = new GZipStream(responseStream, CompressionMode.Decompress, leaveOpen: true)) + using (var sr = new StreamReader(zip)) + //using (var sr = new StreamReader(responseStream)) + sr.ReadToEnd().ShouldEqual(recordedRequest.ResponseBody.ToString()); + + Console.WriteLine("Completed " + i); + } + } + + // ASSERT + + // if we didn't timeout, then we're good + } + [Fact] public async Task CanInterceptAndSpoofWebRequestException() { diff --git a/src/HttpWebRequestWrapper.Tests/HttpWebRequestWrapper.Tests.csproj b/src/HttpWebRequestWrapper.Tests/HttpWebRequestWrapper.Tests.csproj index 8728db5..310dc36 100644 --- a/src/HttpWebRequestWrapper.Tests/HttpWebRequestWrapper.Tests.csproj +++ b/src/HttpWebRequestWrapper.Tests/HttpWebRequestWrapper.Tests.csproj @@ -68,6 +68,7 @@ + @@ -94,6 +95,7 @@ + diff --git a/src/HttpWebRequestWrapper.Tests/InterceptorTests.cs b/src/HttpWebRequestWrapper.Tests/InterceptorTests.cs index ce0c176..9a6246b 100644 --- a/src/HttpWebRequestWrapper.Tests/InterceptorTests.cs +++ b/src/HttpWebRequestWrapper.Tests/InterceptorTests.cs @@ -1,4 +1,5 @@ using System; +using System.Drawing; using System.IO; using System.IO.Compression; using System.Linq; @@ -6,7 +7,9 @@ using System.Text; using System.Threading; using System.Threading.Tasks; +using HttpWebRequestWrapper.Recording; using HttpWebRequestWrapper.Tests.Properties; +using Newtonsoft.Json; using Should; using Xunit; @@ -150,6 +153,38 @@ public void CanSpoofResponseWithStream() sr.ReadToEnd().ShouldEqual(fakeResponseBody); } + [Fact] + public void CanSpoofImageFileResponse() + { + // ARRANGE + var url = new Uri("http://localhost/iisstart.png"); + + string json; + using (var resource = GetType().Assembly.GetManifestResourceStream("HttpWebRequestWrapper.Tests.RecordingSession.json")) + using (var sr = new StreamReader(resource)) + json = sr.ReadToEnd(); + + var recordingSession = JsonConvert.DeserializeObject(json); + + Image image; + + // ACT + using (new HttpWebRequestWrapperSession( + new HttpWebRequestWrapperInterceptorCreator( + new RecordingSessionInterceptorRequestBuilder(recordingSession)))) + { + var request = WebRequest.Create(url); + var response = (HttpWebResponse)request.GetResponse(); + + image = Image.FromStream(response.GetResponseStream()); + } + + // ASSERT + image.ShouldNotBeNull(); + image.Size.Height.ShouldBeGreaterThan(10); + image.Size.Width.ShouldBeGreaterThan(10); + } + [Fact] public void CanSpoofResponseWithCompressedStream() { @@ -386,10 +421,10 @@ public void CanCreateResponseSpecificToRequestPayload() var responseCreator = new Func(req => { - if (req.RequestPayload == fakePayload1) + if (req.RequestPayload.SerializedStream == fakePayload1) return req.HttpWebResponseCreator.Create(fakePayload1Response); - if (req.RequestPayload == fakePayload2) + if (req.RequestPayload.SerializedStream == fakePayload2) return req.HttpWebResponseCreator.Create(fakePayload2Response); throw new Exception("Couldn't match request to response"); @@ -570,7 +605,7 @@ public async Task CanSpoofAsyncRequest() var responseCreator = new Func(req => { - if (req.RequestPayload != requestPayload) + if (req.RequestPayload.SerializedStream != requestPayload) throw new Exception($"{nameof(requestPayload)} was not parsed correctly."); return req.HttpWebResponseCreator.Create(responseBody); diff --git a/src/HttpWebRequestWrapper.Tests/RecorderTests.cs b/src/HttpWebRequestWrapper.Tests/RecorderTests.cs index d93715f..4023aae 100644 --- a/src/HttpWebRequestWrapper.Tests/RecorderTests.cs +++ b/src/HttpWebRequestWrapper.Tests/RecorderTests.cs @@ -1,10 +1,13 @@ using System; using System.Collections.Generic; +using System.Drawing; +using System.Drawing.Imaging; using System.IO; using System.Linq; using System.Net; using System.Threading; using System.Threading.Tasks; +using HttpWebRequestWrapper.Recording; using Newtonsoft.Json; using Should; using Xunit; @@ -12,6 +15,7 @@ // Justification: Test class // ReSharper disable AssignNullToNotNullAttribute // ReSharper disable PossibleNullReferenceException +// ReSharper disable ConvertToConstant.Local namespace HttpWebRequestWrapper.Tests { @@ -216,7 +220,7 @@ public void CanRecordResponse() _data.RecorderResponseBody.ShouldNotBeNull(); _data.RecorderResponseBody.ShouldContain("html"); - _data.RecorderRecording.ResponseBody.ShouldEqual(_data.RecorderResponseBody); + _data.RecorderRecording.ResponseBody.SerializedStream.ShouldEqual(_data.RecorderResponseBody); } [Fact] @@ -245,6 +249,38 @@ public void CanRecordResponseStatusCode() _data.RecorderRecording.ResponseStatusCode.ShouldEqual(_data.RecorderResponse.StatusCode); } + /// + /// When ContentType is application/x-www-form-urlencoded, + /// should be false. + /// + // WARNING!! Makes live request + [Fact(Timeout = 10000)] + public void CanRecordPostWithFormUrlEncoding() + { + // ARRANGE + var url = new Uri("https://www.github.com"); + var payload = "thing1=1&thing2=2"; + + var request = new HttpWebRequestWrapperRecorder(url) + { + Method = "POST", + ContentType = "application/x-www-form-urlencoded" + }; + + using (var sw = new StreamWriter(request.GetRequestStream())) + sw.Write(payload); + + // ACT + var response = request.GetResponse(); + + // ASSERT + response.ShouldNotBeNull(); + + request.RecordedRequests.Count.ShouldEqual(1); + request.RecordedRequests[0].RequestPayload.IsEncoded.ShouldBeFalse(); + request.RecordedRequests[0].RequestPayload.SerializedStream.ShouldEqual(payload); + } + /// /// When the web server returns certain error codes (like a 403), /// will throw a @@ -252,7 +288,7 @@ public void CanRecordResponseStatusCode() /// Make sure this exception gets recorded. /// // WARNING!! Makes live request - [Fact] + [Fact(Timeout = 10000)] public void CanRecordRequestThatThrowsExceptionOnGetResponse() { // ARRANGE @@ -277,13 +313,13 @@ public void CanRecordRequestThatThrowsExceptionOnGetResponse() request.RecordedRequests[0].ResponseException.WebExceptionStatus.ShouldEqual(WebExceptionStatus.ProtocolError); // make sure we recorded the response as well - request.RecordedRequests[0].ResponseBody.ShouldContain(" + /// Record downloading an image file. + /// + // WARNING!! Makes live requests + [Fact(Timeout = 10000)] + public void CanRecordImageFileInResponse() + { + // ARRANGE + var uriToBinaryFile = + new Uri("https://upload.wikimedia.org/wikipedia/commons/thumb/3/35/Tacos_de_Pescado.jpg/320px-Tacos_de_Pescado.jpg"); + + var request = new HttpWebRequestWrapperRecorder(uriToBinaryFile); + + // ACT + var response = request.GetResponse(); + + // ASSERT + response.ShouldNotBeNull(); + + request.RecordedRequests[0].ResponseBody.ShouldNotBeNull(); + request.RecordedRequests[0].ResponseBody.SerializedStream.ShouldNotBeNull(); + request.RecordedRequests[0].ResponseBody.IsEncoded.ShouldBeTrue(); + } + + /// + /// Record uploading an image file. + /// + // WARNING!! Makes live requests + [Fact(Timeout = 10000)] + public void CanRecordImageFileInRequest() + { + var requestUrl = new Uri("http://www.github.com"); + + var request = new HttpWebRequestWrapperRecorder(requestUrl); + + var memoryStream = new MemoryStream(); + + var image =new Bitmap(60, 60); + var graphic = Graphics.FromImage(image); + graphic.DrawEllipse(new Pen(Color.Blue), new Rectangle(20, 20, 10, 10)); + graphic.Save(); + image.Save(memoryStream, ImageFormat.Bmp); + memoryStream.Seek(0, SeekOrigin.Begin); + + // ACT + request.Method = "POST"; + request.ContentType = "images/png"; + memoryStream.CopyTo(request.GetRequestStream()); + + var response = request.GetResponse(); + + // ASSERT + response.ShouldNotBeNull(); + + request.RecordedRequests[0].RequestPayload.ShouldNotBeNull(); + request.RecordedRequests[0].RequestPayload.IsEncoded.ShouldBeTrue(); + request.RecordedRequests[0].RequestPayload.SerializedStream.ShouldNotBeNull(); + request.RecordedRequests[0].RequestPayload.SerializedStream.ShouldStartWith("Qk12"); + + // make sure we can reload the request payload as an image + var requestImage = Image.FromStream(request.RecordedRequests[0].RequestPayload.ToStream()); + requestImage.Height.ShouldEqual(image.Height); + } + [Fact] public void RecordingSessionCanBeSerialized() { diff --git a/src/HttpWebRequestWrapper.Tests/Recording/RecordedStreamTests.cs b/src/HttpWebRequestWrapper.Tests/Recording/RecordedStreamTests.cs new file mode 100644 index 0000000..4a8fa42 --- /dev/null +++ b/src/HttpWebRequestWrapper.Tests/Recording/RecordedStreamTests.cs @@ -0,0 +1,126 @@ +using System; +using System.IO; +using System.IO.Compression; +using System.Net; +using System.Text; +using HttpWebRequestWrapper.Recording; +using Should; +using Xunit; + +namespace HttpWebRequestWrapper.Tests.Recording +{ + /// + /// tests. + /// + public class RecordedStreamTests + { + [Fact] + public void ToStringReturnsStringContentWhenStreamIsEncoded() + { + // ARRANGE + var content = "Hello World"; + + var recordedStream = new RecordedStream( + Encoding.UTF8.GetBytes(content), + new HttpWebRequestWrapper(new Uri("http://fakeSite.fake")) + { + // set content type to force string conetent to be + // encoded + ContentType = "image/png" + }); + + // ACT + var toString = recordedStream.ToString(); + + // ASSERT + recordedStream.IsEncoded.ShouldBeTrue(); + toString.ShouldEqual(content); + } + + [Fact] + public void ToStringReturnsStringContentWhenStreamIsNotEncoded() + { + // ARRANGE + var content = "Hello World"; + + var recordedStream = new RecordedStream( + Encoding.UTF8.GetBytes(content), + new HttpWebRequestWrapper(new Uri("http://fakeSite.fake"))); + + // ACT + var toString = recordedStream.ToString(); + + // ASSERT + recordedStream.IsEncoded.ShouldBeFalse(); + toString.ShouldEqual(content); + } + + [Fact] + public void GZippedStreamIsStoredUncompresseed() + { + // ARRANGE + var content = "Hello World"; + + var compressed = new MemoryStream(); + using (var zip = new GZipStream(compressed, CompressionMode.Compress, leaveOpen: true)) + { + new MemoryStream(Encoding.UTF8.GetBytes(content)).CopyTo(zip); + } + + var recordedStream = new RecordedStream( + compressed.ToArray(), + HttpWebResponseCreator.Create( + new Uri("http://fakeSite.fake"), + "POST", + HttpStatusCode.OK, + compressed, + new WebHeaderCollection + { + {HttpRequestHeader.ContentEncoding, "gzip"} + })); + + // ACT + var toString = recordedStream.ToString(); + + // ASSERT + recordedStream.IsEncoded.ShouldBeFalse(); + recordedStream.IsGzippedCompressed.ShouldBeTrue(); + + toString.ShouldEqual(content); + } + + [Fact] + public void DeflatedStreamIsStoredUncompressed() + { + // ARRANGE + var content = "Hello World"; + + var compressed = new MemoryStream(); + using (var deflate = new DeflateStream(compressed, CompressionMode.Compress, leaveOpen: true)) + { + new MemoryStream(Encoding.UTF8.GetBytes(content)).CopyTo(deflate); + } + + var recordedStream = new RecordedStream( + compressed.ToArray(), + HttpWebResponseCreator.Create( + new Uri("http://fakeSite.fake"), + "POST", + HttpStatusCode.OK, + compressed, + new WebHeaderCollection + { + {HttpRequestHeader.ContentEncoding, "deflate"} + })); + + // ACT + var toString = recordedStream.ToString(); + + // ASSERT + recordedStream.IsEncoded.ShouldBeFalse(); + recordedStream.IsDefalteCompressed.ShouldBeTrue(); + + toString.ShouldEqual(content); + } + } +} diff --git a/src/HttpWebRequestWrapper.Tests/RecordingSession.json b/src/HttpWebRequestWrapper.Tests/RecordingSession.json index f954deb..62c09a6 100644 --- a/src/HttpWebRequestWrapper.Tests/RecordingSession.json +++ b/src/HttpWebRequestWrapper.Tests/RecordingSession.json @@ -7,7 +7,10 @@ "RequestCookieContainer": null, "RequestHeaders": {}, "RequestPayload": "", - "ResponseBody": "\n\n\n\n\n\n\n\n \n \n \n \n \n \n \n \n \n\n\n\n \n \n \n \n \n \n\n \n \n The world's leading software development platform · GitHub\n \n \n \n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n\n \n \n \n \n \n \n\n \n\n \n \n \n \n\n\n\n\n\n\n\n\n \n\n\n \n\n \n \n\n \n \n\n \n\n \n\n \n \n\n \n \n\n\n \n\n\n \n\n \n\n \n \n\n\n\n\n\n \n\n \n \n\n
\n Skip to content\n
\n\n \n \n \n\n\n\n
\n
\n
\n \n \n \n\n\n
\n \n\n
\n\n \n
\n\n
\n \n\n
\n
\n \n\n
\n\n \n \n \n
\n
\n
\n
\n\n
\n\n
\n\n
\n
\n\n\n\n
\n \n\n
\n
\n
\n
\n

Built for developers

\n

\n GitHub is a development platform inspired by the way you work. From open source to business, you can host and review code, manage projects, and build software alongside millions of other developers.\n

\n
\n
\n
\n
\n
\n \n
\n
\n
\n\n
\n
\n
\n GitHub for teams\n
\n

\n A better way to work together\n

\n

\n GitHub brings teams together to work through problems, move ideas forward, and learn from each other along the way.\n

\n \n
\n\n \n\n
\n\n
\n
\n
\n Security and administration\n
\n

\n Boxes? Check.\n

\n

\n We worry about your administrative and security needs so you don’t have to. From flexible hosting to authentication options, GitHub can help you meet your team’s requirements.\n

\n\n

\n \n Learn about GitHub for Business\n \n

\n\n
\n
\n \"Security\n
\n
\n

Code security

\n

\n Prevent problems before they happen. Protected branches, signed commits, and required status checks protect your work and help you maintain a high standard for your code.\n

\n\n

Access controlled

\n

\n Encourage teams to work together while limiting access to those who need it with granular permissions and authentication through SAML/SSO and LDAP.\n

\n
\n
\n\n
\n
\n 1clr-code-hosting\n\n
\n
\n

Hosted where you need it

\n

Securely and reliably host your work on GitHub.com. Or, deploy GitHub Enterprise on your own servers or in a private cloud using Amazon Web Services, Azure or Google Cloud Platform.

\n

\n Compare plans\n

\n
\n
\n\n
\n
\n\n
\n
\n
\n
\n Integrations\n
\n

\n Build on GitHub\n

\n

\n Customize your process with GitHub apps and an intuitive API. Integrate the tools you already use or discover new favorites to create a happier, more efficient way of working.\n

\n

\n Learn about integrations\n

\n
\n\n
\n
\"\"
\n
\"\"
\n
\"\"
\n
\"\"
\n
\"\"
\n
\"\"
\n
\"\"
\n
\n\n
\n

\n Sometimes, there’s more than one tool for the job. Why not try something new?\n

\n

\n Browse GitHub Marketplace\n

\n
\n
\n
\n\n
\n
\n
\n Community\n
\n

\n Welcome home,
developers\n

\n

\n GitHub is home to the world’s largest community of developers and their projects...\n

\n
\n\n \n\n \n
\n\n
\n
\n

\n Get started for free — join the millions of developers already using GitHub to share their code, work together, and build amazing things.\n

\n
\n
\n
\n
\n\n\n
\n\n
\n\n
\n
\n
\n \n

\n © 2018\n

\n
\n \n
\n

Platform

\n \n
\n \n
\n

Company

\n \n
\n
\n

Resources

\n \n
\n
\n
\n\n\n\n\n
\n \n \n You can't perform that action at this time.\n
\n\n\n \n \n \n \n \n \n \n \n
\n \n You signed in with another tab or window. Reload to refresh your session.\n You signed out in another tab or window. Reload to refresh your session.\n
\n
\n
\n
\n
\n \n
\n
\n\n \n\n \n\n\n", + "ResponseBody": { + "SerializedStream": "\n\n\n\n\n\n\n\n \n \n \n \n \n \n \n \n \n\n\n\n \n \n \n \n \n \n\n \n \n The world's leading software development platform · GitHub\n \n \n \n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n\n \n \n \n \n \n \n\n \n\n \n \n \n \n\n\n\n\n\n\n\n\n \n\n\n \n\n \n \n\n \n \n\n \n\n \n\n \n \n\n \n \n\n\n \n\n\n \n\n \n\n \n \n\n\n\n\n\n \n\n \n \n\n
\n Skip to content\n
\n\n \n \n \n\n\n\n
\n
\n
\n \n \n \n\n\n
\n \n\n
\n\n \n
\n\n
\n \n\n
\n
\n \n\n
\n\n \n \n \n
\n
\n
\n
\n\n
\n\n
\n\n
\n
\n\n\n\n
\n \n\n
\n
\n
\n
\n

Built for developers

\n

\n GitHub is a development platform inspired by the way you work. From open source to business, you can host and review code, manage projects, and build software alongside millions of other developers.\n

\n
\n
\n
\n
\n
\n \n
\n
\n
\n\n
\n
\n
\n GitHub for teams\n
\n

\n A better way to work together\n

\n

\n GitHub brings teams together to work through problems, move ideas forward, and learn from each other along the way.\n

\n \n
\n\n \n\n
\n\n
\n
\n
\n Security and administration\n
\n

\n Boxes? Check.\n

\n

\n We worry about your administrative and security needs so you don’t have to. From flexible hosting to authentication options, GitHub can help you meet your team’s requirements.\n

\n\n

\n \n Learn about GitHub for Business\n \n

\n\n
\n
\n \"Security\n
\n
\n

Code security

\n

\n Prevent problems before they happen. Protected branches, signed commits, and required status checks protect your work and help you maintain a high standard for your code.\n

\n\n

Access controlled

\n

\n Encourage teams to work together while limiting access to those who need it with granular permissions and authentication through SAML/SSO and LDAP.\n

\n
\n
\n\n
\n
\n 1clr-code-hosting\n\n
\n
\n

Hosted where you need it

\n

Securely and reliably host your work on GitHub.com. Or, deploy GitHub Enterprise on your own servers or in a private cloud using Amazon Web Services, Azure or Google Cloud Platform.

\n

\n Compare plans\n

\n
\n
\n\n
\n
\n\n
\n
\n
\n
\n Integrations\n
\n

\n Build on GitHub\n

\n

\n Customize your process with GitHub apps and an intuitive API. Integrate the tools you already use or discover new favorites to create a happier, more efficient way of working.\n

\n

\n Learn about integrations\n

\n
\n\n
\n
\"\"
\n
\"\"
\n
\"\"
\n
\"\"
\n
\"\"
\n
\"\"
\n
\"\"
\n
\n\n
\n

\n Sometimes, there’s more than one tool for the job. Why not try something new?\n

\n

\n Browse GitHub Marketplace\n

\n
\n
\n
\n\n
\n
\n
\n Community\n
\n

\n Welcome home,
developers\n

\n

\n GitHub is home to the world’s largest community of developers and their projects...\n

\n
\n\n \n\n \n
\n\n
\n
\n

\n Get started for free — join the millions of developers already using GitHub to share their code, work together, and build amazing things.\n

\n
\n
\n
\n
\n\n\n
\n\n
\n\n
\n
\n
\n \n

\n © 2018\n

\n
\n \n
\n

Platform

\n \n
\n \n
\n

Company

\n \n
\n
\n

Resources

\n \n
\n
\n
\n\n\n\n\n
\n \n \n You can't perform that action at this time.\n
\n\n\n \n \n \n \n \n \n \n \n
\n \n You signed in with another tab or window. Reload to refresh your session.\n You signed out in another tab or window. Reload to refresh your session.\n
\n
\n
\n
\n
\n \n
\n
\n\n \n\n \n\n\n", + "IsEncoded": false + }, "ResponseHeaders": { "Transfer-Encoding": [ "chunked" @@ -71,6 +74,42 @@ ] }, "ResponseStatusCode": 200 + }, + { + "Method": "GET", + "Url": "http://localhost/iisstart.png", + "RequestCookieContainer": null, + "RequestHeaders": {}, + "RequestPayload": "", + "ResponseBody": { + "SerializedStream": "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", + "IsEncoded": true + }, + "ResponseHeaders": { + "Accept-Ranges": [ + "bytes" + ], + "Content-Length": [ + "98757" + ], + "Content-Type": [ + "image/png" + ], + "Date": [ + "Sat, 03 Mar 2018 13:44:11 GMT" + ], + "ETag": [ + "\"371efb4eeb2d31:0\"" + ], + "Last-Modified": [ + "Sat, 03 Mar 2018 12:49:07 GMT" + ], + "Server": [ + "Microsoft-IIS/10.0" + ] + }, + "ResponseStatusCode": 200, + "ResponseException": null } ] } diff --git a/src/HttpWebRequestWrapper.Tests/RecordingSessionInterceptorRequestBuilderTests.cs b/src/HttpWebRequestWrapper.Tests/RecordingSessionInterceptorRequestBuilderTests.cs index 27f7a81..c4a3a38 100644 --- a/src/HttpWebRequestWrapper.Tests/RecordingSessionInterceptorRequestBuilderTests.cs +++ b/src/HttpWebRequestWrapper.Tests/RecordingSessionInterceptorRequestBuilderTests.cs @@ -1,8 +1,11 @@ using System; using System.Collections.Generic; using System.IO; +using System.IO.Compression; using System.Linq; using System.Net; +using System.Text; +using HttpWebRequestWrapper.Recording; using HttpWebRequestWrapper.Tests.Properties; using Newtonsoft.Json; using Should; @@ -108,10 +111,10 @@ public void CanPlaybackFromMultipleRecordingSessions() response2.ShouldNotBeNull(); using (var sr = new StreamReader(response1.GetResponseStream())) - sr.ReadToEnd().ShouldEqual(recordedRequest1.ResponseBody); + sr.ReadToEnd().ShouldEqual(recordedRequest1.ResponseBody.SerializedStream); using (var sr = new StreamReader(response2.GetResponseStream())) - sr.ReadToEnd().ShouldEqual(recordedRequest2.ResponseBody); + sr.ReadToEnd().ShouldEqual(recordedRequest2.ResponseBody.SerializedStream); } [Fact] @@ -220,7 +223,7 @@ public void DefaultNotFoundBehaviorReturns404() } // WARNING!! Makes live request - [Fact] + [Fact(Timeout = 10000)] public void CanChangeDefaultNotFoundBehaviorToPassThrough() { // ARRANGE @@ -329,7 +332,7 @@ public void CanCustomizeMatchingAlgorithm() response.ShouldNotBeNull(); using (var sr = new StreamReader(response.GetResponseStream())) - sr.ReadToEnd().ShouldEqual(recordedRequest.ResponseBody); + sr.ReadToEnd().ShouldEqual(recordedRequest.ResponseBody.SerializedStream); } [Fact] @@ -446,13 +449,13 @@ public void CanSetRecordedRequestsToOnlyMatchOnce() response2b.ShouldNotBeNull(); using (var sr = new StreamReader(response1a.GetResponseStream())) - sr.ReadToEnd().ShouldEqual(recordedRequest1.ResponseBody); + sr.ReadToEnd().ShouldEqual(recordedRequest1.ResponseBody.SerializedStream); using (var sr = new StreamReader(response1b.GetResponseStream())) - sr.ReadToEnd().ShouldEqual(recordedRequest1.ResponseBody); + sr.ReadToEnd().ShouldEqual(recordedRequest1.ResponseBody.SerializedStream); using (var sr = new StreamReader(response2a.GetResponseStream())) - sr.ReadToEnd().ShouldEqual(recordedRequest2.ResponseBody); + sr.ReadToEnd().ShouldEqual(recordedRequest2.ResponseBody.SerializedStream); response1c.StatusCode.ShouldEqual(HttpStatusCode.NotFound); response2b.StatusCode.ShouldEqual(HttpStatusCode.NotFound); @@ -497,10 +500,10 @@ public void MatchesOnUniqueUrl() response2.ShouldNotBeNull(); using (var sr = new StreamReader(response1.GetResponseStream())) - sr.ReadToEnd().ShouldEqual(recordedRequest1.ResponseBody); + sr.ReadToEnd().ShouldEqual(recordedRequest1.ResponseBody.SerializedStream); using (var sr = new StreamReader(response2.GetResponseStream())) - sr.ReadToEnd().ShouldEqual(recordedRequest2.ResponseBody); + sr.ReadToEnd().ShouldEqual(recordedRequest2.ResponseBody.SerializedStream); } [Fact] @@ -544,10 +547,10 @@ public void MatchesOnUniqueMethod() response2.ShouldNotBeNull(); using (var sr = new StreamReader(response1.GetResponseStream())) - sr.ReadToEnd().ShouldEqual(recordedRequest1.ResponseBody); + sr.ReadToEnd().ShouldEqual(recordedRequest1.ResponseBody.SerializedStream); using (var sr = new StreamReader(response2.GetResponseStream())) - sr.ReadToEnd().ShouldEqual(recordedRequest2.ResponseBody); + sr.ReadToEnd().ShouldEqual(recordedRequest2.ResponseBody.SerializedStream); } [Fact] @@ -559,14 +562,19 @@ public void MatchesOnUniquePayload() Url = "http://fakeSite.fake", Method = "POST", RequestPayload = "Request 1", + RequestHeaders = new RecordedHeaders + { + {"Content-Type", new []{"text/plain" }} + }, ResponseBody = "Response 1" }; var recordedRequest2 = new RecordedRequest { Url = recordedRequest1.Url, - Method = "POST", + Method = recordedRequest1.Method, RequestPayload = "Request 2", + RequestHeaders = recordedRequest1.RequestHeaders, ResponseBody = "Response 2" }; @@ -581,13 +589,15 @@ public void MatchesOnUniquePayload() var request1 = creator.Create(new Uri(recordedRequest1.Url)); request1.Method = "POST"; - using (var sw = new StreamWriter(request1.GetRequestStream())) - sw.Write(recordedRequest1.RequestPayload); + request1.ContentType = "text/plain"; + + recordedRequest1.RequestPayload.ToStream().CopyTo(request1.GetRequestStream()); var request2 = creator.Create(new Uri(recordedRequest2.Url)); request2.Method = "POST"; - using (var sw = new StreamWriter(request2.GetRequestStream())) - sw.Write(recordedRequest2.RequestPayload); + request2.ContentType = "text/plain"; + + recordedRequest2.RequestPayload.ToStream().CopyTo(request2.GetRequestStream()); // ACT var response1 = request1.GetResponse(); @@ -598,10 +608,10 @@ public void MatchesOnUniquePayload() response2.ShouldNotBeNull(); using (var sr = new StreamReader(response1.GetResponseStream())) - sr.ReadToEnd().ShouldEqual(recordedRequest1.ResponseBody); + sr.ReadToEnd().ShouldEqual(recordedRequest1.ResponseBody.SerializedStream); using (var sr = new StreamReader(response2.GetResponseStream())) - sr.ReadToEnd().ShouldEqual(recordedRequest2.ResponseBody); + sr.ReadToEnd().ShouldEqual(recordedRequest2.ResponseBody.SerializedStream); } [Fact] @@ -649,10 +659,145 @@ public void MatchesOnUniqueRequestHeaders() response2.ShouldNotBeNull(); using (var sr = new StreamReader(response1.GetResponseStream())) - sr.ReadToEnd().ShouldEqual(recordedRequest1.ResponseBody); + sr.ReadToEnd().ShouldEqual(recordedRequest1.ResponseBody.SerializedStream); using (var sr = new StreamReader(response2.GetResponseStream())) - sr.ReadToEnd().ShouldEqual(recordedRequest2.ResponseBody); + sr.ReadToEnd().ShouldEqual(recordedRequest2.ResponseBody.SerializedStream); + } + + [Fact] + public void CanPlaybackZippedResponse() + { + // ARRANGE + var recordedRequest = new RecordedRequest + { + Url = "http://fakeSite.fake", + Method = "GET", + ResponseBody = new RecordedStream + { + SerializedStream = "Response 1", + IsGzippedCompressed = true + }, + ResponseHeaders = new RecordedHeaders + { + {"Content-Encoding", new []{"gzip"} } + } + }; + + var recordingSession = new RecordingSession + { + RecordedRequests = new List { recordedRequest } + }; + + var requestBuilder = new RecordingSessionInterceptorRequestBuilder(recordingSession); + + IWebRequestCreate creator = new HttpWebRequestWrapperInterceptorCreator(requestBuilder); + + var request = (HttpWebRequest)creator.Create(new Uri(recordedRequest.Url)); + request.AutomaticDecompression = DecompressionMethods.GZip; + + // ACT + var response = request.GetResponse(); + + // ASSERT + response.ShouldNotBeNull(); + + using (var sr = new StreamReader(response.GetResponseStream())) + sr.ReadToEnd().ShouldEqual(recordedRequest.ResponseBody.SerializedStream); + } + + [Fact] + public void CanPlaybackDeflatedResponse() + { + // ARRANGE + var recordedRequest = new RecordedRequest + { + Url = "http://fakeSite.fake", + Method = "GET", + ResponseBody = new RecordedStream + { + SerializedStream = "Response 1", + IsDefalteCompressed = true + }, + ResponseHeaders = new RecordedHeaders + { + {"Content-Encoding", new []{"deflate"} } + } + }; + + var recordingSession = new RecordingSession + { + RecordedRequests = new List { recordedRequest } + }; + + var requestBuilder = new RecordingSessionInterceptorRequestBuilder(recordingSession); + + IWebRequestCreate creator = new HttpWebRequestWrapperInterceptorCreator(requestBuilder); + + var request = (HttpWebRequest)creator.Create(new Uri(recordedRequest.Url)); + request.AutomaticDecompression = DecompressionMethods.Deflate; + + // ACT + var response = request.GetResponse(); + + // ASSERT + response.ShouldNotBeNull(); + + using (var sr = new StreamReader(response.GetResponseStream())) + sr.ReadToEnd().ShouldEqual(recordedRequest.ResponseBody.SerializedStream); + } + + [Fact] + public void MatchesOnZippedPayload() + { + // ARRANGE + var recordedRequest = new RecordedRequest + { + Url = "http://fakeSite.fake", + Method = "POST", + RequestPayload = new RecordedStream + { + SerializedStream = "Request 1", + IsGzippedCompressed = true + }, + RequestHeaders = new RecordedHeaders + { + {"Content-Type", new []{"text/plain" }} + }, + ResponseBody = "Response 1" + }; + + var recordingSession = new RecordingSession + { + RecordedRequests = new List { recordedRequest } + }; + + var requestBuilder = new RecordingSessionInterceptorRequestBuilder(recordingSession); + + IWebRequestCreate creator = new HttpWebRequestWrapperInterceptorCreator(requestBuilder); + + var request = creator.Create(new Uri(recordedRequest.Url)); + request.Method = "POST"; + request.ContentType = "text/plain"; + + using (var input = new MemoryStream(Encoding.UTF8.GetBytes(recordedRequest.RequestPayload.SerializedStream))) + using (var compressed = new MemoryStream()) + using (var zip = new GZipStream(compressed, CompressionMode.Compress, leaveOpen: true)) + { + input.CopyTo(zip); + zip.Close(); + compressed.Seek(0, SeekOrigin.Begin); + compressed.CopyTo(request.GetRequestStream()); + } + + // ACT + var response = request.GetResponse(); + + // ASSERT + response.ShouldNotBeNull(); + + using (var sr = new StreamReader(response.GetResponseStream())) + sr.ReadToEnd().ShouldEqual(recordedRequest.ResponseBody.SerializedStream); } [Fact] @@ -681,7 +826,7 @@ public void BuilderSetsResponseBody() response.ShouldNotBeNull(); using (var sr = new StreamReader(response.GetResponseStream())) - sr.ReadToEnd().ShouldEqual(recordedRequest.ResponseBody); + sr.ReadToEnd().ShouldEqual(recordedRequest.ResponseBody.SerializedStream); } [Fact] @@ -818,7 +963,60 @@ public void BuilderSetsWebExceptionWithResponse() webExceptionResponse.ContentLength.ShouldBeGreaterThan(0); using (var sr = new StreamReader(webExceptionResponse.GetResponseStream())) - sr.ReadToEnd().ShouldEqual(recordedRequest.ResponseBody); + sr.ReadToEnd().ShouldEqual(recordedRequest.ResponseBody.SerializedStream); + } + + /// + /// From documentation + /// https://msdn.microsoft.com/en-us/library/system.net.webexception.response(v=vs.110).aspx + /// Response should always be set if is + /// + /// + [Fact] + public void BuilderAlwaysSetsWebExcpetionResponseWhenStatusIsProtocolError() + { + // ARRANGE + var recordedRequest = new RecordedRequest + { + Url = "http://fakeSite.fake", + Method = "GET", + ResponseException = new RecordedResponseException + { + Message = "Test Exception Message", + Type = typeof(WebException), + WebExceptionStatus = WebExceptionStatus.ProtocolError, + }, + ResponseHeaders = new RecordedHeaders + { + {"header1", new[] {"value1"}} + }, + ResponseStatusCode = HttpStatusCode.Unauthorized + //intentionally leave ResponseBody null + }; + + var recordingSession = new RecordingSession { RecordedRequests = new List { recordedRequest } }; + + var requestBuilder = new RecordingSessionInterceptorRequestBuilder(recordingSession); + + IWebRequestCreate creator = new HttpWebRequestWrapperInterceptorCreator(requestBuilder); + + var request = creator.Create(new Uri(recordedRequest.Url)); + + // ACT + var exception = Record.Exception(() => request.GetResponse()); + var webException = exception as WebException; + var webExceptionResponse = webException.Response as HttpWebResponse; + + // ASSERT + webException.ShouldNotBeNull(); + webException.Message.ShouldEqual(recordedRequest.ResponseException.Message); + webException.Status.ShouldEqual(recordedRequest.ResponseException.WebExceptionStatus.Value); + + webExceptionResponse.ShouldNotBeNull(); + Assert.Equal(recordedRequest.ResponseHeaders, (RecordedHeaders)webExceptionResponse.Headers); + webExceptionResponse.StatusCode.ShouldEqual(recordedRequest.ResponseStatusCode); + // no response content in recordedResponse, so content length should be 0 + webExceptionResponse.ContentLength.ShouldEqual(0); } [Fact] diff --git a/src/HttpWebRequestWrapper/Extensions/RecordedRequestExtensions.cs b/src/HttpWebRequestWrapper/Extensions/RecordedRequestExtensions.cs index 546fadb..8037fe3 100644 --- a/src/HttpWebRequestWrapper/Extensions/RecordedRequestExtensions.cs +++ b/src/HttpWebRequestWrapper/Extensions/RecordedRequestExtensions.cs @@ -1,5 +1,7 @@ using System; +using System.IO; using System.Net; +using HttpWebRequestWrapper.Recording; namespace HttpWebRequestWrapper.Extensions { @@ -56,7 +58,11 @@ public static bool TryGetResponseException(this RecordedRequest request, out Exc return true; } - if (null == request.ResponseBody) + // can we return a WebException without a Response? + if (string.IsNullOrEmpty(request?.ResponseBody?.SerializedStream) && + // always need to return a response if WebExceptionStatus is ProtocolError + //https://msdn.microsoft.com/en-us/library/system.net.webexception.response(v=vs.110).aspx + request.ResponseException.WebExceptionStatus != WebExceptionStatus.ProtocolError) { recordedException = new WebException( request.ResponseException.Message, @@ -76,7 +82,7 @@ public static bool TryGetResponseException(this RecordedRequest request, out Exc new Uri(request.Url), request.Method, request.ResponseStatusCode, - request.ResponseBody, + request.ResponseBody?.ToStream() ?? new MemoryStream(), request.ResponseHeaders)); return true; diff --git a/src/HttpWebRequestWrapper/Extensions/StreamExtensions.cs b/src/HttpWebRequestWrapper/Extensions/StreamExtensions.cs new file mode 100644 index 0000000..ad90caf --- /dev/null +++ b/src/HttpWebRequestWrapper/Extensions/StreamExtensions.cs @@ -0,0 +1,22 @@ +using System.IO; + +namespace HttpWebRequestWrapper.Extensions +{ + internal static class StreamExtensions + { + public static void CopyTo(this Stream source, Stream destinaton) + { + var buffer = new byte[1024]; + + while (true) + { + var read = source.Read(buffer, 0, buffer.Length); + + destinaton.Write(buffer, 0, read); + + if (read != buffer.Length) + break; + } + } + } +} diff --git a/src/HttpWebRequestWrapper/HttpWebRequestWrapper.csproj b/src/HttpWebRequestWrapper/HttpWebRequestWrapper.csproj index d44d311..2c0dc71 100644 --- a/src/HttpWebRequestWrapper/HttpWebRequestWrapper.csproj +++ b/src/HttpWebRequestWrapper/HttpWebRequestWrapper.csproj @@ -49,6 +49,7 @@ + @@ -59,9 +60,11 @@ - - - + + + + + diff --git a/src/HttpWebRequestWrapper/HttpWebRequestWrapperDelegateCreator.cs b/src/HttpWebRequestWrapper/HttpWebRequestWrapperDelegateCreator.cs index 5c27a39..e65ff1d 100644 --- a/src/HttpWebRequestWrapper/HttpWebRequestWrapperDelegateCreator.cs +++ b/src/HttpWebRequestWrapper/HttpWebRequestWrapperDelegateCreator.cs @@ -1,5 +1,6 @@ using System; using System.Net; +using HttpWebRequestWrapper.Recording; namespace HttpWebRequestWrapper { diff --git a/src/HttpWebRequestWrapper/HttpWebRequestWrapperInterceptor.cs b/src/HttpWebRequestWrapper/HttpWebRequestWrapperInterceptor.cs index 6190574..f138b84 100644 --- a/src/HttpWebRequestWrapper/HttpWebRequestWrapperInterceptor.cs +++ b/src/HttpWebRequestWrapper/HttpWebRequestWrapperInterceptor.cs @@ -3,7 +3,7 @@ using System.Net; using System.Reflection; using System.Threading; -using HttpWebRequestWrapper.IO; +using HttpWebRequestWrapper.Recording; namespace HttpWebRequestWrapper { @@ -35,7 +35,27 @@ public HttpWebRequestWrapperInterceptor(Uri uri, Func + /// + /// This override is very important. It greatly + /// speeds up execution during interception when an async + /// caller (ie HttpClient) wants to GetRequestStream. + /// + /// Enabling this override was also found to be the solution for + /// https://github.com/ppittle/HttpWebRequestWrapper/issues/21 + /// where the 3rd HttpClient.PostAsync call would stall here. + /// + public override IAsyncResult BeginGetRequestStream(AsyncCallback callback, object state) + { + var asyncResult = new DummyAsyncResult(new ManualResetEvent(true), state); + + callback?.Invoke(asyncResult); + + return asyncResult; + } + /// public override Stream GetRequestStream() { @@ -54,9 +74,12 @@ public override WebResponse GetResponse() HttpWebResponse passThroughShadowCopy = null; var interceptedRequest = new InterceptedRequest { - RequestPayload = _requestStream.ReadToEnd(), + RequestPayload = + new RecordedStream( + _requestStream.ToArray(), + this), HttpWebRequest = this, - HttpWebResponseCreator = new HttpWebResponseInterceptorCreator(RequestUri, Method), + HttpWebResponseCreator = new HttpWebResponseInterceptorCreator(RequestUri, Method, AutomaticDecompression), PassThroughResponse = () => { // if we are going to pass through - we need to use the base.GetRequest diff --git a/src/HttpWebRequestWrapper/HttpWebRequestWrapperInterceptorCreator.cs b/src/HttpWebRequestWrapper/HttpWebRequestWrapperInterceptorCreator.cs index 53a2c3f..e79f98a 100644 --- a/src/HttpWebRequestWrapper/HttpWebRequestWrapperInterceptorCreator.cs +++ b/src/HttpWebRequestWrapper/HttpWebRequestWrapperInterceptorCreator.cs @@ -1,5 +1,6 @@ using System; using System.Net; +using HttpWebRequestWrapper.Recording; namespace HttpWebRequestWrapper { diff --git a/src/HttpWebRequestWrapper/HttpWebRequestWrapperRecorder.cs b/src/HttpWebRequestWrapper/HttpWebRequestWrapperRecorder.cs index 2721f69..25c695e 100644 --- a/src/HttpWebRequestWrapper/HttpWebRequestWrapperRecorder.cs +++ b/src/HttpWebRequestWrapper/HttpWebRequestWrapperRecorder.cs @@ -3,6 +3,7 @@ using System.IO; using System.Net; using HttpWebRequestWrapper.IO; +using HttpWebRequestWrapper.Recording; // Justification: Improves readability // ReSharper disable ConvertIfStatementToNullCoalescingExpression @@ -96,7 +97,12 @@ private HttpWebResponse RecordRequestAndResponse(Func getRespon Method = Method, RequestCookieContainer = CookieContainer, RequestHeaders = Headers, - RequestPayload = _shadowCopyRequestStream.ReadToEnd() + RequestPayload = + null == _shadowCopyRequestStream + ? new RecordedStream() + : new RecordedStream( + _shadowCopyRequestStream.ShadowCopy.ToArray(), + this) }; RecordedRequests.Add(recordedRequest); @@ -157,11 +163,10 @@ private void RecordResponse(HttpWebResponse response, RecordedRequest recordedRe // seek to beginning so we can read the memory stream memoryStream.Seek(0, SeekOrigin.Begin); - using (var sr = new StreamReader(memoryStream)) - recordedRequest.ResponseBody = sr.ReadToEnd(); - - // reset the stream - stream reader closes the first one - memoryStream = new MemoryStream(memoryStream.ToArray()); + recordedRequest.ResponseBody = + new RecordedStream( + memoryStream.ToArray(), + response); // replace the default stream in response with the copy ReflectionExtensions.SetField(response, "m_ConnectStream", memoryStream); diff --git a/src/HttpWebRequestWrapper/HttpWebRequestWrapperRecorderCreator.cs b/src/HttpWebRequestWrapper/HttpWebRequestWrapperRecorderCreator.cs index b3aeb33..1b71883 100644 --- a/src/HttpWebRequestWrapper/HttpWebRequestWrapperRecorderCreator.cs +++ b/src/HttpWebRequestWrapper/HttpWebRequestWrapperRecorderCreator.cs @@ -1,5 +1,6 @@ using System; using System.Net; +using HttpWebRequestWrapper.Recording; // Justification: Public Api // ReSharper disable MemberCanBePrivate.Global diff --git a/src/HttpWebRequestWrapper/HttpWebRequestWrapperSession.cs b/src/HttpWebRequestWrapper/HttpWebRequestWrapperSession.cs index dd3fc05..ac5d9b3 100644 --- a/src/HttpWebRequestWrapper/HttpWebRequestWrapperSession.cs +++ b/src/HttpWebRequestWrapper/HttpWebRequestWrapperSession.cs @@ -65,7 +65,7 @@ public virtual void Dispose() #region WebRequest Prefix helpers - private static PropertyInfo WebRequestPrefixListProperty = + private static readonly PropertyInfo _webRequestPrefixListProperty = typeof(WebRequest) .GetProperty( "PrefixList", @@ -73,13 +73,13 @@ public virtual void Dispose() private static ArrayList GetWebRequestPrefixList() { - var prefixList = (ArrayList)WebRequestPrefixListProperty.GetValue(null, new object[0]); + var prefixList = (ArrayList)_webRequestPrefixListProperty.GetValue(null, new object[0]); return (ArrayList) prefixList.Clone(); } private static void SetWebRequestPrefixList(ArrayList prefixList) { - WebRequestPrefixListProperty.SetValue(null, prefixList, new object[0]); + _webRequestPrefixListProperty.SetValue(null, prefixList, new object[0]); } #endregion diff --git a/src/HttpWebRequestWrapper/HttpWebResponseCreator.cs b/src/HttpWebRequestWrapper/HttpWebResponseCreator.cs index ae7b6b7..abe1c41 100644 --- a/src/HttpWebRequestWrapper/HttpWebResponseCreator.cs +++ b/src/HttpWebRequestWrapper/HttpWebResponseCreator.cs @@ -19,8 +19,8 @@ namespace HttpWebRequestWrapper { /// /// Helper on top of that - /// pre-populates and when - /// building . + /// pre-populates , + /// and when building . /// /// Use these methods to build a real functioning /// without having to deal with the reflection head-aches of doing it manually. @@ -29,6 +29,7 @@ public class HttpWebResponseInterceptorCreator { private readonly Uri _responseUri; private readonly string _method; + private readonly DecompressionMethods _automaticDecompression; /// /// Creates a new @@ -38,10 +39,12 @@ public class HttpWebResponseInterceptorCreator /// public HttpWebResponseInterceptorCreator( Uri responseUri, - string method) + string method, + DecompressionMethods automaticDecompression) { _responseUri = responseUri; _method = method; + _automaticDecompression = automaticDecompression; } /// @@ -72,7 +75,8 @@ public HttpWebResponse Create( _method, statusCode, responseBody, - responseHeaders); + responseHeaders, + _automaticDecompression); } /// @@ -108,7 +112,7 @@ public HttpWebResponse Create( Stream responseStream, HttpStatusCode statusCode = HttpStatusCode.OK, WebHeaderCollection responseHeaders = null, - DecompressionMethods decompressionMethod = DecompressionMethods.None, + DecompressionMethods? decompressionMethod = null, long? contentLength = null) { return HttpWebResponseCreator.Create( @@ -117,7 +121,7 @@ public HttpWebResponse Create( statusCode, responseStream, responseHeaders ?? new WebHeaderCollection(), - decompressionMethod, + decompressionMethod ?? _automaticDecompression, contentLength: contentLength); } @@ -181,7 +185,7 @@ public HttpWebResponse Create( HttpStatusCode statusCode, Stream responseStream, WebHeaderCollection responseHeaders, - DecompressionMethods decompressionMethod = DecompressionMethods.None, + DecompressionMethods? decompressionMethod = null, string mediaType = null, long? contentLength = null, string statusDescription = null, @@ -196,7 +200,7 @@ public HttpWebResponse Create( statusCode, responseStream, responseHeaders, - decompressionMethod, + decompressionMethod ?? _automaticDecompression, mediaType, contentLength, statusDescription, @@ -237,12 +241,18 @@ public static class HttpWebResponseCreator /// /// Use this to also set Cookies via /// + /// + /// OPTIONAL: Controls if will decompress + /// in its constructor. + /// Default is + /// public static HttpWebResponse Create( Uri responseUri, string method, HttpStatusCode statusCode, string responseBody, - WebHeaderCollection responseHeaders = null) + WebHeaderCollection responseHeaders = null, + DecompressionMethods decompressionMethod = DecompressionMethods.None) { // allow responseBody to be null - but change to empty string responseBody = responseBody ?? string.Empty; @@ -255,7 +265,8 @@ public static HttpWebResponse Create( method, statusCode, responseStream, - responseHeaders); + responseHeaders, + decompressionMethod); } /// diff --git a/src/HttpWebRequestWrapper/IO/MemoryStreamExtensions.cs b/src/HttpWebRequestWrapper/IO/MemoryStreamExtensions.cs deleted file mode 100644 index ec89d1f..0000000 --- a/src/HttpWebRequestWrapper/IO/MemoryStreamExtensions.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System.IO; - -namespace HttpWebRequestWrapper.IO -{ - internal static class MemoryStreamExtensions - { - public static string ReadToEnd(this MemoryStream stream) - { - if (null == stream) - return string.Empty; - - // read even if stream is closed - var copy = new MemoryStream(stream.ToArray()); - - using (var sr = new StreamReader(copy)) - return sr.ReadToEnd(); - } - } -} diff --git a/src/HttpWebRequestWrapper/IO/ShadowCopyStream.cs b/src/HttpWebRequestWrapper/IO/ShadowCopyStream.cs index 3ac34c5..00603ba 100644 --- a/src/HttpWebRequestWrapper/IO/ShadowCopyStream.cs +++ b/src/HttpWebRequestWrapper/IO/ShadowCopyStream.cs @@ -1,7 +1,5 @@ -using System; -using System.IO; +using System.IO; using System.Net; -using System.Text; namespace HttpWebRequestWrapper.IO { @@ -72,32 +70,4 @@ public override long Position set => _primaryStream.Position = value; } } - - /// - /// Add-ons for - /// - internal static class ShadowCopySteamExtensions - { - internal static string ReadToEnd(this ShadowCopyStream shadowCopyStream) - { - if (null == shadowCopyStream) - return string.Empty; - - if (shadowCopyStream.ShadowCopy.Length == 0) - return string.Empty; - - try - { - shadowCopyStream.ShadowCopy.Seek(0, SeekOrigin.Begin); - - using (var sr = new StreamReader(shadowCopyStream.ShadowCopy, Encoding.UTF8)) - return sr.ReadToEnd(); - } - catch (Exception e) - { - // suppress exception, but update history - return $"ERROR: {e.Message}\r\n{e.StackTrace}"; - } - } - } } \ No newline at end of file diff --git a/src/HttpWebRequestWrapper/InterceptedRequest.cs b/src/HttpWebRequestWrapper/InterceptedRequest.cs index db360d4..6094b81 100644 --- a/src/HttpWebRequestWrapper/InterceptedRequest.cs +++ b/src/HttpWebRequestWrapper/InterceptedRequest.cs @@ -1,6 +1,7 @@ using System; using System.Diagnostics; using System.Net; +using HttpWebRequestWrapper.Recording; namespace HttpWebRequestWrapper { @@ -22,7 +23,7 @@ public class InterceptedRequest /// Don't try and read this from , the request stream /// has probably already been closed. /// - public string RequestPayload { get; set; } + public RecordedStream RequestPayload { get; set; } /// /// The that has been intercepted. /// Use this to read , etc diff --git a/src/HttpWebRequestWrapper/RecordedRequest.cs b/src/HttpWebRequestWrapper/RecordedRequest.cs deleted file mode 100644 index 967c49f..0000000 --- a/src/HttpWebRequestWrapper/RecordedRequest.cs +++ /dev/null @@ -1,207 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Linq; -using System.Net; -using HttpWebRequestWrapper.Extensions; - -namespace HttpWebRequestWrapper -{ - /// - /// Request / Response data recorded by . Can be played back - /// using and . - /// - /// Supports serialization to JSON! Perfect for saving as an embedded resource in your test projects! - /// - /// See for more information. - /// - [DebuggerDisplay("{Method} {Url}")] - public class RecordedRequest - { - /// - /// Recorded - /// - public string Method { get;set; } - /// - /// Recorded - /// - public string Url { get; set; } - /// - /// Recorded - /// - /// This is mostly exposed for convenience. This data will also - /// be contained in . - /// - public CookieContainer RequestCookieContainer { get; set; } - /// - /// Recorded - /// - /// NOTE: From MS Documentation: - /// https://msdn.microsoft.com/en-us/library/system.net.httpwebrequest.headers%28v=vs.110%29.aspx - /// You should not assume that the header values will remain unchanged, - /// because Web servers and caches may change or add headers to a Web request. - /// - /// are recorded *before* - /// is called, so this might not be the same as - /// after calling - /// - public RecordedHeaders RequestHeaders { get; set; } = new RecordedHeaders(); - /// - /// Recorded - /// - public string RequestPayload { get; set; } - /// - /// Recorded - /// - public string ResponseBody { get; set; } - /// - /// Recorded - /// - public RecordedHeaders ResponseHeaders { get; set; } = new RecordedHeaders(); - /// - /// Recorded - /// - public HttpStatusCode ResponseStatusCode { get; set; } - /// - /// Recorded information captured - /// during . - /// - /// If no exception was thrown, this will be null. - /// - /// Use - /// to convert this to a strongly typed exception instance. - /// - public RecordedResponseException ResponseException { get; set; } - } - - /// - /// Helper class for dealing with - - /// primarily here to support json serialization as - /// objects don't serialize correctly. - /// - /// Supports two implicit conversions to/from . - /// - /// Also has some equality methods that were useful when unit testing the - /// library. - /// - public class RecordedHeaders : Dictionary, - IEquatable, - IEquatable - { - /// - /// Implicit conversion from a to a - /// . - /// - public static implicit operator WebHeaderCollection(RecordedHeaders headers) - { - if (null == headers) - return null; - - var webHeaders = new WebHeaderCollection(); - - foreach (var kvp in headers) - foreach(var value in kvp.Value) - { - webHeaders.Add(kvp.Key, value); - } - - return webHeaders; - } - - /// - /// Implicit conversion from a to a - /// . - /// - public static implicit operator RecordedHeaders(WebHeaderCollection webHeader) - { - if (null == webHeader) - return null; - - var recordedHeaders = new RecordedHeaders(); - - foreach (var key in webHeader.AllKeys) - { - var values = webHeader.GetValues(key); - - recordedHeaders.Add(key, values ?? new string[0]); - } - - return recordedHeaders; - } - - /// - /// Performs an equality comparison with an external - /// . - /// - /// Don't care about ordering, just make sure both dictionaries - /// contain every key, and they have the same array of strings for every - /// key. All string comparisons are case sensitive. - /// - public bool Equals(RecordedHeaders other) - { - if (null == other) - return false; - - // make sure we have the same number of keys - // and every key in this dictionary exists in - // other and the other dictionary has the same string[] - // associated with key. string comparisons are default (case-sensitive) - // but order doesn't matter. - return - Count == other.Count && - this.All(kvp => - other.Any(otherKvp => - string.Equals(kvp.Key, otherKvp.Key) && - kvp.Value.Length == otherKvp.Value.Length && - kvp.Value.All(v => otherKvp.Value.Contains(v)) - )); - } - - /// - /// Performs an equality comparison with an external - /// by casting - /// to a and then using - /// - /// - public bool Equals(WebHeaderCollection other) - { - return Equals((RecordedHeaders) other); - } - } - - /// - /// A specialized container for collection s - /// recorded during a . - /// - /// This collection is optimized for serialization, as unfortunately - /// objects don't reliably support xml serialization. - /// - /// NOTE: Currently this object only supports capturing - /// for all exceptions and for . - /// All other exception properties will be discarded. - /// - /// See - /// for information on how this object is consumer and converted back into - /// an exception. - /// - [DebuggerDisplay("{Type.Name}: {Message}")] - public class RecordedResponseException - { - /// - /// - /// - public string Message { get; set; } - /// - /// . This is captured - /// so the correctly typed exception can be built from - /// this . - /// - public Type Type { get; set; } - /// - /// . - /// This will be null if is not - /// - /// - public WebExceptionStatus? WebExceptionStatus { get; set; } - } -} diff --git a/src/HttpWebRequestWrapper/Recording/RecordedHeaders.cs b/src/HttpWebRequestWrapper/Recording/RecordedHeaders.cs new file mode 100644 index 0000000..8aca6ad --- /dev/null +++ b/src/HttpWebRequestWrapper/Recording/RecordedHeaders.cs @@ -0,0 +1,102 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; + +namespace HttpWebRequestWrapper.Recording +{ + /// + /// Helper class for dealing with - + /// primarily here to support json serialization as + /// objects don't serialize correctly. + /// + /// Supports two implicit conversions to/from . + /// + /// Also has some equality methods that were useful when unit testing the + /// library. + /// + public class RecordedHeaders : Dictionary, + IEquatable, + IEquatable + { + /// + /// Implicit conversion from a to a + /// . + /// + public static implicit operator WebHeaderCollection(RecordedHeaders headers) + { + if (null == headers) + return null; + + var webHeaders = new WebHeaderCollection(); + + foreach (var kvp in headers) + foreach(var value in kvp.Value) + { + webHeaders.Add(kvp.Key, value); + } + + return webHeaders; + } + + /// + /// Implicit conversion from a to a + /// . + /// + public static implicit operator RecordedHeaders(WebHeaderCollection webHeader) + { + if (null == webHeader) + return null; + + var recordedHeaders = new RecordedHeaders(); + + foreach (var key in webHeader.AllKeys) + { + var values = webHeader.GetValues(key); + + recordedHeaders.Add(key, values ?? new string[0]); + } + + return recordedHeaders; + } + + /// + /// Performs an equality comparison with an external + /// . + /// + /// Don't care about ordering, just make sure both dictionaries + /// contain every key, and they have the same array of strings for every + /// key. All string comparisons are case sensitive. + /// + public bool Equals(RecordedHeaders other) + { + if (null == other) + return false; + + // make sure we have the same number of keys + // and every key in this dictionary exists in + // other and the other dictionary has the same string[] + // associated with key. string comparisons are default (case-sensitive) + // but order doesn't matter. + return + Count == other.Count && + this.All(kvp => + other.Any(otherKvp => + string.Equals(kvp.Key, otherKvp.Key) && + kvp.Value.Length == otherKvp.Value.Length && + kvp.Value.All(v => otherKvp.Value.Contains(v)) + )); + } + + /// + /// Performs an equality comparison with an external + /// by casting + /// to a and then using + /// + /// + public bool Equals(WebHeaderCollection other) + { + return Equals((RecordedHeaders) other); + } + } +} \ No newline at end of file diff --git a/src/HttpWebRequestWrapper/Recording/RecordedRequest.cs b/src/HttpWebRequestWrapper/Recording/RecordedRequest.cs new file mode 100644 index 0000000..26b407b --- /dev/null +++ b/src/HttpWebRequestWrapper/Recording/RecordedRequest.cs @@ -0,0 +1,77 @@ +using System; +using System.Diagnostics; +using System.Net; +using HttpWebRequestWrapper.Extensions; + +// Justification: Can't use nameof in attriutes (ie DebuggerDisplay) +// ReSharper disable UseNameofExpression + +namespace HttpWebRequestWrapper.Recording +{ + /// + /// Request / Response data recorded by . Can be played back + /// using and . + /// + /// Supports serialization to JSON! Perfect for saving as an embedded resource in your test projects! + /// + /// See for more information. + /// + [DebuggerDisplay("{Method} {Url}")] + public class RecordedRequest + { + /// + /// Recorded + /// + public string Method { get;set; } + /// + /// Recorded + /// + public string Url { get; set; } + /// + /// Recorded + /// + /// This is mostly exposed for convenience. This data will also + /// be contained in . + /// + public CookieContainer RequestCookieContainer { get; set; } + /// + /// Recorded + /// + /// NOTE: From MS Documentation: + /// https://msdn.microsoft.com/en-us/library/system.net.httpwebrequest.headers%28v=vs.110%29.aspx + /// You should not assume that the header values will remain unchanged, + /// because Web servers and caches may change or add headers to a Web request. + /// + /// are recorded *before* + /// is called, so this might not be the same as + /// after calling + /// + public RecordedHeaders RequestHeaders { get; set; } = new RecordedHeaders(); + /// + /// Recorded + /// + public RecordedStream RequestPayload { get; set; } = new RecordedStream(); + /// + /// Recorded + /// + public RecordedStream ResponseBody { get; set; } = new RecordedStream(); + /// + /// Recorded + /// + public RecordedHeaders ResponseHeaders { get; set; } = new RecordedHeaders(); + /// + /// Recorded + /// + public HttpStatusCode ResponseStatusCode { get; set; } + /// + /// Recorded information captured + /// during . + /// + /// If no exception was thrown, this will be null. + /// + /// Use + /// to convert this to a strongly typed exception instance. + /// + public RecordedResponseException ResponseException { get; set; } + } +} diff --git a/src/HttpWebRequestWrapper/Recording/RecordedResponseException.cs b/src/HttpWebRequestWrapper/Recording/RecordedResponseException.cs new file mode 100644 index 0000000..8380fa8 --- /dev/null +++ b/src/HttpWebRequestWrapper/Recording/RecordedResponseException.cs @@ -0,0 +1,43 @@ +using System; +using System.Diagnostics; +using System.Net; +using HttpWebRequestWrapper.Extensions; + +namespace HttpWebRequestWrapper.Recording +{ + /// + /// A specialized container for collection s + /// recorded during a . + /// + /// This collection is optimized for serialization, as unfortunately + /// objects don't reliably support xml serialization. + /// + /// NOTE: Currently this object only supports capturing + /// for all exceptions and for . + /// All other exception properties will be discarded. + /// + /// See + /// for information on how this object is consumer and converted back into + /// an exception. + /// + [DebuggerDisplay("{Type.Name}: {Message}")] + public class RecordedResponseException + { + /// + /// + /// + public string Message { get; set; } + /// + /// . This is captured + /// so the correctly typed exception can be built from + /// this . + /// + public Type Type { get; set; } + /// + /// . + /// This will be null if is not + /// + /// + public WebExceptionStatus? WebExceptionStatus { get; set; } + } +} \ No newline at end of file diff --git a/src/HttpWebRequestWrapper/Recording/RecordedStream.cs b/src/HttpWebRequestWrapper/Recording/RecordedStream.cs new file mode 100644 index 0000000..efe7e68 --- /dev/null +++ b/src/HttpWebRequestWrapper/Recording/RecordedStream.cs @@ -0,0 +1,313 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Net; +using System.Text; +using HttpWebRequestWrapper.Extensions; + +// Justification: Can't use nameof in attriutes (ie DebuggerDisplay) +// ReSharper disable UseNameofExpression + +// Justificaton: Public Api +// ReSharper disable MemberCanBePrivate.Global + +// Justification: Prefer instance methods +// ReSharper disable MemberCanBeMadeStatic.Local + +namespace HttpWebRequestWrapper.Recording +{ + /// + /// Specialized container for recording + /// and . Examines content type/encoding + /// and if content reports to be text, stream is stored plain-text, otherwise, content + /// is stored base64. This way binary request/responses can be recorded and serialized. + /// + /// The effort is made to store plain text content as plain-text, as opposed to + /// storing everything base64, so that when this class is serialized, it's easier + /// to read / modify recorded content. + /// + [DebuggerDisplay("{SerializedStream}")] + public class RecordedStream : IEquatable + { + /// + /// Serialized stream. If is true, + /// this is stored as a Base64 string, otherwise + /// stored plain text. + /// + /// If you want to get the string content of this + /// it's + /// recommended to use rather than + /// using directly. + /// + public string SerializedStream { get; set; } + + /// + /// Indicates if is encoded. + /// + public bool IsEncoded { get; set; } + + /// + /// Indicates should be GZip + /// compressed when is called. + /// + public bool IsGzippedCompressed { get; set; } + + /// + /// Indicates should + /// be compressed with the Deflate aglorithm when + /// is called. + /// + public bool IsDefalteCompressed { get; set; } + + /// + /// Creates an empty . + /// is intiailized to + /// + public RecordedStream() + { + SerializedStream = string.Empty; + IsEncoded = false; + } + + /// + /// Creates a new around + /// . + /// + /// If 's + /// is empty or can be inferred to represent plain text then + /// is stored in + /// via . + /// Otherwise, is stored as base64 string. + /// + public RecordedStream( + byte[] streamBytes, + HttpWebRequest request) + { + if (streamBytes.Length == 0) + { + SerializedStream = string.Empty; + return; + } + + streamBytes = TryAndUnzipStream(streamBytes); + + if (ContentTypeIsForPlainText(request.ContentType)) + { + SerializedStream = Encoding.UTF8.GetString(streamBytes); + } + else + { + SerializedStream = Convert.ToBase64String(streamBytes); + IsEncoded = true; + } + } + + /// + /// Creates a new around + /// . + /// + /// If 's + /// is empty or can be inferred to represent plain text OR + /// is "utf-8" + /// is stored in + /// via . + /// Otherwise, is stored as base64 string. + /// + public RecordedStream( + byte[] streamBytes, + HttpWebResponse response) + { + if (streamBytes.Length == 0) + { + SerializedStream = string.Empty; + return; + } + + if (response.ContentEncoding.ToLower().Contains("gzip")) + streamBytes = TryAndUnzipStream(streamBytes); + + if (response.ContentEncoding.ToLower().Contains("deflate")) + streamBytes = TryAndDeflateStream(streamBytes); + + if ( + response.CharacterSet?.ToLower() == "utf-8" || + ContentTypeIsForPlainText(response.ContentType)) + { + SerializedStream = Encoding.UTF8.GetString(streamBytes); + } + else + { + SerializedStream = Convert.ToBase64String(streamBytes); + IsEncoded = true; + } + } + + private byte[] TryAndUnzipStream(byte[] streamBytes) + { + // check if streamBytes starts with gzip header + // https://stackoverflow.com/questions/4662821/is-there-a-way-to-know-if-the-byte-has-been-compressed-by-gzipstream + + if (streamBytes.Length < 3) + return streamBytes; + + var gzipHeader = new byte[] {0x1f, 0x8b, 8}; + + if (!streamBytes.Take(3).SequenceEqual(gzipHeader)) + return streamBytes; + + // at this point streamBytes is probably compressed, only way to know for sure + // is to try and decompress it + try + { + using (var compressedStream = new MemoryStream(streamBytes)) + using (var zipStream = new GZipStream(compressedStream, CompressionMode.Decompress)) + using (var decompressed = new MemoryStream()) + { + zipStream.CopyTo(decompressed); + + IsGzippedCompressed = true; + return decompressed.ToArray(); + } + } + catch + { + return streamBytes; + } + } + + private byte[] TryAndDeflateStream(byte[] streamBytes) + { + if (streamBytes.Length == 0) + return streamBytes; + + // don't know of a way to pre-emptively guess if stream is compressed with deflate + // have to try to deflate in a try/catch + try + { + using (var compressedStream = new MemoryStream(streamBytes)) + using (var deflateStream = new DeflateStream(compressedStream, CompressionMode.Decompress)) + using (var decompressed = new MemoryStream()) + { + deflateStream.CopyTo(decompressed); + + IsDefalteCompressed = true; + return decompressed.ToArray(); + } + } + catch + { + return streamBytes; + } + } + + private bool ContentTypeIsForPlainText(string contentType) + { + return + // assume if contenttype are empty that + // we do **not** need to encode streamBytes + string.IsNullOrEmpty(contentType) || + contentType.ToLower().Contains("text") || + contentType.ToLower().Contains("xml") || + contentType.ToLower().Contains("json") || + contentType.ToLower().Contains("application/x-www-form-urlencoded"); + } + + /// + /// Builds a new correclty + /// populated with the content of + /// + public Stream ToStream() + { + var baseStream = new MemoryStream( + IsEncoded + ? Convert.FromBase64String(SerializedStream ?? "") + : Encoding.UTF8.GetBytes(SerializedStream)); + + if (IsGzippedCompressed) + { + var compressed = new MemoryStream(); + + using (var zip = new GZipStream(compressed, CompressionMode.Compress, leaveOpen: true)) + baseStream.CopyTo(zip); + + compressed.Seek(0, SeekOrigin.Begin); + return compressed; + } + else if (IsDefalteCompressed) + { + var compressed = new MemoryStream(); + + using (var deflate = new DeflateStream(compressed, CompressionMode.Compress, leaveOpen: true)) + baseStream.CopyTo(deflate); + + compressed.Seek(0, SeekOrigin.Begin); + return compressed; + } + else + { + return baseStream; + } + } + + /// + /// Returns the Stream content as to as close as a useable + /// string as possible. + /// + /// Returns un-encoded and + /// un-compressed. If represents + /// binanry conent, this will not be useful. However, if for some + /// reason is string content but has + /// been marked , this will return a usable string. + /// + /// This is the perferred way of getting the Stream as a string. It + /// is unadvisable to inspect directly. + /// + /// + public override string ToString() + { + if (string.IsNullOrEmpty(SerializedStream)) + return string.Empty; + + var baseStream = new MemoryStream( + IsEncoded + ? Convert.FromBase64String(SerializedStream ?? "") + : Encoding.UTF8.GetBytes(SerializedStream)); + + using (var sr = new StreamReader(baseStream)) + return sr.ReadToEnd(); + } + + /// + /// Builds a new from , + /// storing as plain text in . + /// + /// This makes it very easy to assign string text directly to . + /// + public static implicit operator RecordedStream(string textResponse) + { + return new RecordedStream + { + SerializedStream = textResponse, + IsEncoded = false + }; + } + + /// + /// Determines equality betweeen and this + /// . This allows comparing s + /// easier for things like + /// as well as tests. + /// + public bool Equals(RecordedStream other) + { + if (null == other) + return string.IsNullOrEmpty(SerializedStream); + + return + IsEncoded == other.IsEncoded && + SerializedStream == other.SerializedStream; + } + } +} \ No newline at end of file diff --git a/src/HttpWebRequestWrapper/RecordingSession.cs b/src/HttpWebRequestWrapper/Recording/RecordingSession.cs similarity index 96% rename from src/HttpWebRequestWrapper/RecordingSession.cs rename to src/HttpWebRequestWrapper/Recording/RecordingSession.cs index f7b184a..284724f 100644 --- a/src/HttpWebRequestWrapper/RecordingSession.cs +++ b/src/HttpWebRequestWrapper/Recording/RecordingSession.cs @@ -1,6 +1,6 @@ using System.Collections.Generic; -namespace HttpWebRequestWrapper +namespace HttpWebRequestWrapper.Recording { /// /// Collection of s. diff --git a/src/HttpWebRequestWrapper/RecordingSessionInterceptorRequestBuilder.cs b/src/HttpWebRequestWrapper/RecordingSessionInterceptorRequestBuilder.cs index 255f8a7..dce4b4e 100644 --- a/src/HttpWebRequestWrapper/RecordingSessionInterceptorRequestBuilder.cs +++ b/src/HttpWebRequestWrapper/RecordingSessionInterceptorRequestBuilder.cs @@ -4,6 +4,7 @@ using System.Linq; using System.Net; using HttpWebRequestWrapper.Extensions; +using HttpWebRequestWrapper.Recording; // Justification: Public Api // ReSharper disable MemberCanBePrivate.Global @@ -215,10 +216,7 @@ private bool DefaultMatchingAlgorithm( StringComparison.InvariantCultureIgnoreCase); var requestPayloadMatches = - string.Equals( - interceptedRequest.RequestPayload ?? "", - recordedRequest.RequestPayload ?? "", - StringComparison.InvariantCultureIgnoreCase); + true == interceptedRequest?.RequestPayload.Equals(recordedRequest.RequestPayload); var requestHeadersMatch = recordedRequest.RequestHeaders.Equals(interceptedRequest.HttpWebRequest.Headers); @@ -244,7 +242,7 @@ private HttpWebResponse DefaultRecordedResultResponseBuilder( throw recordedException; return interceptedRequest.HttpWebResponseCreator.Create( - recordedRequest.ResponseBody, + recordedRequest.ResponseBody.ToStream(), recordedRequest.ResponseStatusCode, headers); }