From 53d29b372e237ef7482ed1b76ae7b22fed8cb267 Mon Sep 17 00:00:00 2001 From: Sander van Vliet Date: Mon, 15 Apr 2024 16:36:02 +0200 Subject: [PATCH] Support matching request content --- Changelog.md | 20 +++++++ ...zer.HttpClient.Testable.v3.ncrunchsolution | 2 + Directory.Build.props | 2 +- README.md | 28 +++++++++ .../ConfigurationDumpVisitor.cs | 5 ++ .../ConfiguredRequests.cs | 8 ++- .../IRequestBuilder.cs | 7 +++ .../RequestBuilder.cs | 11 ++++ .../RequestContentNode.cs | 60 +++++++++++++++++++ .../RequestHeadersNode.cs | 31 +++++++--- .../RequestNodeVisitor.cs | 1 + .../RequestQueryNode.cs | 3 +- .../WhenHandlingRequest.cs | 46 ++++++++++++++ 13 files changed, 211 insertions(+), 13 deletions(-) create mode 100644 src/Codenizer.HttpClient.Testable/RequestContentNode.cs diff --git a/Changelog.md b/Changelog.md index 5354619..0d0b10d 100644 --- a/Changelog.md +++ b/Changelog.md @@ -1,5 +1,25 @@ # Codenizer.HttpClient.Testable Changelog +## 2.9.0.0 + +Add support to configure expected request content. +This can be particularly useful if you want to match a route for a specific request body. + +For example: + +```csharp +handler + .RespondTo() + .Post() + .ForUrl("/search") + .ForContent(@"{""params"":{""foo"":""bar""}}") + .With(HttpStatusCode.OK); +``` + +will only match when the request body is _exactly_ `{"params":{"foo":"bar"}}`. + +Currently only string content is supported. + ## 2.8.0.0 Add support to supply a lambda to create the response that's sent to the client. It allows you to generate a more dynamic response if the path you're configuring requires that. diff --git a/Codenizer.HttpClient.Testable.v3.ncrunchsolution b/Codenizer.HttpClient.Testable.v3.ncrunchsolution index 10420ac..fe9b070 100644 --- a/Codenizer.HttpClient.Testable.v3.ncrunchsolution +++ b/Codenizer.HttpClient.Testable.v3.ncrunchsolution @@ -1,6 +1,8 @@  True + False + True True \ No newline at end of file diff --git a/Directory.Build.props b/Directory.Build.props index 0272551..e77353d 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,7 +1,7 @@ - 2.8.0.0 + 2.9.0.0 Sander van Vliet Codenizer BV 2024 Sander van Vliet diff --git a/README.md b/README.md index b71c067..f2a7add 100644 --- a/README.md +++ b/README.md @@ -372,6 +372,34 @@ await httpClient.PostAsync("/api/infos", new StringContent("test", Encoding.ASCI the response returned from the handler will be `415 Unsupported Media Type` +### Handle requests only if they match the request body + +For some tests you might want to configure the handler to return certain responses based on the request content. + +To do this you can configure the handler like so: + +```csharp +handler + .RespondTo() + .Post() + .ForUrl("/search") + .ForContent(@"{""params"":{""match"":""bar""}}") + .With(HttpStatusCode.BadRequest); +``` + +and a second one like so: + +```csharp +handler + .RespondTo() + .Post() + .ForUrl("/search") + .ForContent(@"{""params"":{""match"":false}}") + .With(HttpStatusCode.OK); +``` + +Here you can see that the same endpoint returns two different responses based on the request content. + ### A sequence of responses In some cases you might want to have multiple responses configured for the same endpoint, for example when you call a status endpoint of a job. diff --git a/src/Codenizer.HttpClient.Testable/ConfigurationDumpVisitor.cs b/src/Codenizer.HttpClient.Testable/ConfigurationDumpVisitor.cs index e3eac73..a5d31c2 100644 --- a/src/Codenizer.HttpClient.Testable/ConfigurationDumpVisitor.cs +++ b/src/Codenizer.HttpClient.Testable/ConfigurationDumpVisitor.cs @@ -100,5 +100,10 @@ public override void Response(RequestBuilder requestBuilder) } } } + + public override void Content(string expectedContent) + { + _indentedWriter.WriteLine($"Content: {expectedContent}"); + } } } \ No newline at end of file diff --git a/src/Codenizer.HttpClient.Testable/ConfiguredRequests.cs b/src/Codenizer.HttpClient.Testable/ConfiguredRequests.cs index e65ba36..c7d869c 100644 --- a/src/Codenizer.HttpClient.Testable/ConfiguredRequests.cs +++ b/src/Codenizer.HttpClient.Testable/ConfiguredRequests.cs @@ -42,7 +42,9 @@ public ConfiguredRequests(IEnumerable requestBuilders) var headersNode = queryNode.Add(headers); - headersNode.SetRequestBuilder(requestBuilder); + var contentNode = headersNode.Add(requestBuilder.ExpectedContent); + + contentNode.SetRequestBuilder(requestBuilder); Count++; } @@ -124,7 +126,9 @@ private static void ThrowIfRouteIsNotFullyConfigured(RequestBuilder route) var headersNode = queryNode.Match(httpRequestMessage.Headers); - return headersNode?.RequestBuilder; + var contentNode = headersNode?.Match(httpRequestMessage.Content); + + return contentNode?.RequestBuilder; } internal static ConfiguredRequests FromRequestBuilders(IEnumerable requestBuilders) diff --git a/src/Codenizer.HttpClient.Testable/IRequestBuilder.cs b/src/Codenizer.HttpClient.Testable/IRequestBuilder.cs index a469639..da20b88 100644 --- a/src/Codenizer.HttpClient.Testable/IRequestBuilder.cs +++ b/src/Codenizer.HttpClient.Testable/IRequestBuilder.cs @@ -94,5 +94,12 @@ public interface IRequestBuilder /// A MIME content type (for example text/plain) /// The current instance IRequestBuilder Accepting(string mimeType); + + /// + /// Respond to a request that matches the request content + /// + /// + /// The current instance + IRequestBuilder ForContent(string content); } } \ No newline at end of file diff --git a/src/Codenizer.HttpClient.Testable/RequestBuilder.cs b/src/Codenizer.HttpClient.Testable/RequestBuilder.cs index 9d61616..4a72861 100644 --- a/src/Codenizer.HttpClient.Testable/RequestBuilder.cs +++ b/src/Codenizer.HttpClient.Testable/RequestBuilder.cs @@ -119,6 +119,11 @@ internal RequestBuilder(HttpMethod method, string pathAndQuery, string? contentT /// public string? Accept { get; private set; } + /// + /// Optional. The expected content of the request. + /// + public string? ExpectedContent { get; private set; } + /// public IResponseBuilder With(HttpStatusCode statusCode) { @@ -247,6 +252,12 @@ public IRequestBuilder Accepting(string mimeType) return this; } + public IRequestBuilder ForContent(string content) + { + ExpectedContent = content; + return this; + } + /// public IResponseBuilder AndContent(string mimeType, object data) { diff --git a/src/Codenizer.HttpClient.Testable/RequestContentNode.cs b/src/Codenizer.HttpClient.Testable/RequestContentNode.cs new file mode 100644 index 0000000..abd3e51 --- /dev/null +++ b/src/Codenizer.HttpClient.Testable/RequestContentNode.cs @@ -0,0 +1,60 @@ +using System.Net.Http; + +namespace Codenizer.HttpClient.Testable; + +internal class RequestContentNode : RequestNode +{ + private readonly string? _expectedContent; + + public RequestContentNode(string? expectedContent) + { + _expectedContent = expectedContent; + } + + public override void Accept(RequestNodeVisitor visitor) + { + if (_expectedContent != null) + { + visitor.Content(_expectedContent); + } + + if (RequestBuilder != null) + { + visitor.Response(RequestBuilder); + } + } + + public RequestBuilder? RequestBuilder { get; private set; } + + public void SetRequestBuilder(RequestBuilder requestBuilder) + { + if (RequestBuilder != null) + { + throw new MultipleResponsesConfiguredException(2, requestBuilder.PathAndQuery!); + } + + RequestBuilder = requestBuilder; + } + + public bool Match(HttpContent content) + { + if (_expectedContent == null) + { + return true; + } + + if (content is StringContent stringContent) + { + var requestContent = stringContent.ReadAsStringAsync().GetAwaiter().GetResult(); + + return string.Equals(_expectedContent, requestContent); + } + + return false; + } + + public bool Match(string? expectedContent) + { + return string.Equals(_expectedContent, expectedContent); + } +} \ No newline at end of file diff --git a/src/Codenizer.HttpClient.Testable/RequestHeadersNode.cs b/src/Codenizer.HttpClient.Testable/RequestHeadersNode.cs index b792fcd..87163e6 100644 --- a/src/Codenizer.HttpClient.Testable/RequestHeadersNode.cs +++ b/src/Codenizer.HttpClient.Testable/RequestHeadersNode.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using System.Linq; +using System.Net.Http; using System.Net.Http.Headers; namespace Codenizer.HttpClient.Testable @@ -7,6 +8,7 @@ namespace Codenizer.HttpClient.Testable internal class RequestHeadersNode : RequestNode { private readonly Dictionary _headers; + private readonly List _requestContentNodes = new(); public RequestHeadersNode(Dictionary headers) { @@ -63,14 +65,9 @@ public bool Match(HttpRequestHeaders headers) return true; } - public void SetRequestBuilder(RequestBuilder requestBuilder) + public RequestContentNode? Match(HttpContent content) { - if(RequestBuilder != null) - { - throw new MultipleResponsesConfiguredException(2, requestBuilder.PathAndQuery!); - } - - RequestBuilder = requestBuilder; + return _requestContentNodes.SingleOrDefault(node => node.Match(content)); } public override void Accept(RequestNodeVisitor visitor) @@ -80,10 +77,26 @@ public override void Accept(RequestNodeVisitor visitor) visitor.Header(header.Key, header.Value); } - if (RequestBuilder != null) + foreach (var node in _requestContentNodes) + { + node.Accept(visitor); + } + } + + public RequestContentNode Add(string? expectedContent) + { + var existingContentNode = _requestContentNodes.SingleOrDefault(node => node.Match(expectedContent)); + + if (existingContentNode == null) { - visitor.Response(RequestBuilder); + var requestContentNode = new RequestContentNode(expectedContent); + + _requestContentNodes.Add(requestContentNode); + + return requestContentNode; } + + return existingContentNode; } } } \ No newline at end of file diff --git a/src/Codenizer.HttpClient.Testable/RequestNodeVisitor.cs b/src/Codenizer.HttpClient.Testable/RequestNodeVisitor.cs index 59c8c09..1b8a8b8 100644 --- a/src/Codenizer.HttpClient.Testable/RequestNodeVisitor.cs +++ b/src/Codenizer.HttpClient.Testable/RequestNodeVisitor.cs @@ -11,5 +11,6 @@ internal abstract class RequestNodeVisitor public abstract void Scheme(string scheme); public abstract void Method(HttpMethod method); public abstract void Response(RequestBuilder requestBuilder); + public abstract void Content(string expectedContent); } } \ No newline at end of file diff --git a/src/Codenizer.HttpClient.Testable/RequestQueryNode.cs b/src/Codenizer.HttpClient.Testable/RequestQueryNode.cs index db66ff6..51e1f37 100644 --- a/src/Codenizer.HttpClient.Testable/RequestQueryNode.cs +++ b/src/Codenizer.HttpClient.Testable/RequestQueryNode.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using System.Linq; +using System.Net.Http; using System.Net.Http.Headers; namespace Codenizer.HttpClient.Testable @@ -103,7 +104,7 @@ public RequestHeadersNode Add(Dictionary headers) { return _headersNodes.SingleOrDefault(node => node.Match(headers)); } - + public override void Accept(RequestNodeVisitor visitor) { if (_queryParameters.Any()) diff --git a/test/Codenizer.HttpClient.Testable.Tests.Unit/WhenHandlingRequest.cs b/test/Codenizer.HttpClient.Testable.Tests.Unit/WhenHandlingRequest.cs index 89c486b..ce14b29 100644 --- a/test/Codenizer.HttpClient.Testable.Tests.Unit/WhenHandlingRequest.cs +++ b/test/Codenizer.HttpClient.Testable.Tests.Unit/WhenHandlingRequest.cs @@ -612,5 +612,51 @@ public async Task GivenAsyncHandlerOnRequestThatReadsRequestContent_ContentCanSt content.Should().Be("HELLO WORLD!"); } + + [Fact] + public async Task GivenExpectationForContentAndContentMatches_ConfiguredResponseIsReturned() + { + var handler = new TestableMessageHandler(); + var client = new System.Net.Http.HttpClient(handler); + + handler + .RespondTo() + .Post() + .ForUrl("/search") + .ForContent(@"{""params"":{""query_string"":""confetti""}}") + .With(HttpStatusCode.OK); + + var response = await client.PostAsync( + "https://tempuri.org/search", + new StringContent(@"{""params"":{""query_string"":""confetti""}}")); + + response + .StatusCode + .Should() + .Be(HttpStatusCode.OK); + } + + [Fact] + public async Task GivenExpectationWithContentAndContentDoesNotMatch_InternalServerErrorIsReturned() + { + var handler = new TestableMessageHandler(); + var client = new System.Net.Http.HttpClient(handler); + + handler + .RespondTo() + .Post() + .ForUrl("/search") + .ForContent(@"{""params"":{""query_string"":""confetti""}}") + .With(HttpStatusCode.OK); + + var response = await client.PostAsync( + "https://tempuri.org/search", + new StringContent(@"{""boo"":""baz""}")); + + response + .StatusCode + .Should() + .Be(HttpStatusCode.InternalServerError); + } } } \ No newline at end of file