From 1ec352f125655cec9d96e4de7cbf8e3b2fa1f478 Mon Sep 17 00:00:00 2001 From: Joshua Waring Date: Wed, 8 Jan 2025 12:26:02 +0100 Subject: [PATCH] Predicate based matching on HttpRequestMessage --- .../ConfigurationDumpVisitor.cs | 8 ++- .../ConfiguredRequests.cs | 24 +++++-- .../IRequestBuilder.cs | 9 +++ .../RequestBuilder.cs | 15 +++++ .../RequestNodeVisitor.cs | 4 +- .../RequestPathNode.cs | 30 ++++++++- .../RequestWhenNode.cs | 63 +++++++++++++++++++ .../WhenMatchingRoutes.cs | 45 +++++++++++++ 8 files changed, 189 insertions(+), 9 deletions(-) create mode 100644 src/Codenizer.HttpClient.Testable/RequestWhenNode.cs diff --git a/src/Codenizer.HttpClient.Testable/ConfigurationDumpVisitor.cs b/src/Codenizer.HttpClient.Testable/ConfigurationDumpVisitor.cs index a5d31c2..59b6835 100644 --- a/src/Codenizer.HttpClient.Testable/ConfigurationDumpVisitor.cs +++ b/src/Codenizer.HttpClient.Testable/ConfigurationDumpVisitor.cs @@ -1,4 +1,5 @@ -using System.CodeDom.Compiler; +using System; +using System.CodeDom.Compiler; using System.IO; using System.Linq; using System.Net.Http; @@ -66,6 +67,11 @@ public override void Scheme(string scheme) _indentedWriter.WriteLine($"{scheme}://"); } + public override void When(object userData) { + _indentedWriter.Indent = 1; + _indentedWriter.WriteLine($"When: {userData}"); + } + public override void Method(HttpMethod method) { _indentedWriter.Indent = 0; diff --git a/src/Codenizer.HttpClient.Testable/ConfiguredRequests.cs b/src/Codenizer.HttpClient.Testable/ConfiguredRequests.cs index c7d869c..ef63024 100644 --- a/src/Codenizer.HttpClient.Testable/ConfiguredRequests.cs +++ b/src/Codenizer.HttpClient.Testable/ConfiguredRequests.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Net.Http; @@ -35,8 +35,15 @@ public ConfiguredRequests(IEnumerable requestBuilders) .Split('?').First(); var pathNode = authorityNode.Add(path); - - var queryNode = pathNode.Add(requestBuilder.QueryParameters, requestBuilder.QueryStringAssertions); + + RequestQueryNode queryNode; + if (requestBuilder.Predicate != null) { + var whenNode = pathNode.Add(requestBuilder.Predicate, requestBuilder.UserObject); + queryNode = whenNode.Add(requestBuilder.QueryParameters, requestBuilder.QueryStringAssertions); + } + else + queryNode = pathNode.Add(requestBuilder.QueryParameters, requestBuilder.QueryStringAssertions); + var headers = requestBuilder.BuildRequestHeaders(); @@ -117,7 +124,16 @@ private static void ThrowIfRouteIsNotFullyConfigured(RequestBuilder route) ? httpRequestMessage.RequestUri.OriginalString.Split('?').Last() : null; - var queryNode = pathNode.Match(query); + + RequestQueryNode queryNode; + if (pathNode.HasWhenNodes()) { + var whenNode = pathNode.Match(httpRequestMessage); + if (whenNode == null) + return null; + queryNode = whenNode.Match(query); + } + else + queryNode = pathNode.Match(query); if (queryNode == null) { diff --git a/src/Codenizer.HttpClient.Testable/IRequestBuilder.cs b/src/Codenizer.HttpClient.Testable/IRequestBuilder.cs index da20b88..c34007c 100644 --- a/src/Codenizer.HttpClient.Testable/IRequestBuilder.cs +++ b/src/Codenizer.HttpClient.Testable/IRequestBuilder.cs @@ -1,5 +1,6 @@ using System; using System.Net; +using System.Net.Http; namespace Codenizer.HttpClient.Testable { @@ -88,6 +89,14 @@ public interface IRequestBuilder /// The current instance IRequestBuilder AndContentType(string contentType); + + /// + /// Respond to a request that matches the given content type + /// + /// A MIME content type (for example text/plain) + /// The current instance + IRequestBuilder AndWhen(object userData, Func predicate); + /// /// Respond to a request that matches the accept header /// diff --git a/src/Codenizer.HttpClient.Testable/RequestBuilder.cs b/src/Codenizer.HttpClient.Testable/RequestBuilder.cs index 4a72861..b793e40 100644 --- a/src/Codenizer.HttpClient.Testable/RequestBuilder.cs +++ b/src/Codenizer.HttpClient.Testable/RequestBuilder.cs @@ -74,6 +74,15 @@ internal RequestBuilder(HttpMethod method, string pathAndQuery, string? contentT /// public object? Data { get; private set; } /// + /// Optional. A user defined function to inspect the message and make a dynamic Match decision + /// + public Func? Predicate { get; private set; } + /// + /// When Predicate is set, the UserObject is defined and passed to the predicate + /// Also used to Add in the RequestBuilders + /// + public object? UserObject { get; private set; } + /// /// Optional. The callback to invoke when generating the response to a request. /// public Func? ResponseCallback { get; private set; } @@ -245,6 +254,12 @@ public IRequestBuilder AndContentType(string contentType) return this; } + public IRequestBuilder AndWhen(object userData, Func predicate) { + Predicate = predicate; + UserObject = userData; + return this; + } + /// public IRequestBuilder Accepting(string mimeType) { diff --git a/src/Codenizer.HttpClient.Testable/RequestNodeVisitor.cs b/src/Codenizer.HttpClient.Testable/RequestNodeVisitor.cs index 1b8a8b8..db594a3 100644 --- a/src/Codenizer.HttpClient.Testable/RequestNodeVisitor.cs +++ b/src/Codenizer.HttpClient.Testable/RequestNodeVisitor.cs @@ -1,4 +1,5 @@ -using System.Net.Http; +using System; +using System.Net.Http; namespace Codenizer.HttpClient.Testable { @@ -9,6 +10,7 @@ internal abstract class RequestNodeVisitor public abstract void Path(string path); public abstract void Authority(string authority); public abstract void Scheme(string scheme); + public abstract void When(object userData); public abstract void Method(HttpMethod method); public abstract void Response(RequestBuilder requestBuilder); public abstract void Content(string expectedContent); diff --git a/src/Codenizer.HttpClient.Testable/RequestPathNode.cs b/src/Codenizer.HttpClient.Testable/RequestPathNode.cs index e917d0c..aec257b 100644 --- a/src/Codenizer.HttpClient.Testable/RequestPathNode.cs +++ b/src/Codenizer.HttpClient.Testable/RequestPathNode.cs @@ -1,18 +1,35 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Linq; +using System.Net.Http; namespace Codenizer.HttpClient.Testable { internal class RequestPathNode : RequestNode { public string Path { get; } - private readonly List _queryNodes = new List(); + private readonly List _whenNodes = new (); + private readonly List _queryNodes = new (); public RequestPathNode(string path) { Path = path; } + public RequestWhenNode Add(Func? predicate, object? userValue) { + + var existingWhen = _whenNodes.SingleOrDefault(node => node.Matches(userValue)); + + if (existingWhen is null) { + _whenNodes.Add(existingWhen = new RequestWhenNode( + predicate ?? ((_, _) => true), + userValue ?? new object())); + } + + return existingWhen; + } + + public RequestQueryNode Add( List> queryParameters, List queryStringAssertions) @@ -33,6 +50,13 @@ public RequestQueryNode Add( return _queryNodes.SingleOrDefault(node => node.Matches(queryString)); } + public RequestWhenNode? Match(HttpRequestMessage request) + { + return _whenNodes.SingleOrDefault(node => node.Matches(request)); + } + + public bool HasWhenNodes() => _whenNodes.Any(); + public bool MatchesPath(string path) { if (PathHasRouteParameters()) @@ -82,7 +106,7 @@ public override void Accept(RequestNodeVisitor visitor) { visitor.Path(Path); - foreach (var node in _queryNodes) + foreach (var node in _whenNodes) { node.Accept(visitor); } diff --git a/src/Codenizer.HttpClient.Testable/RequestWhenNode.cs b/src/Codenizer.HttpClient.Testable/RequestWhenNode.cs new file mode 100644 index 0000000..5559372 --- /dev/null +++ b/src/Codenizer.HttpClient.Testable/RequestWhenNode.cs @@ -0,0 +1,63 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Net.Http.Headers; + +namespace Codenizer.HttpClient.Testable +{ + internal class RequestWhenNode : RequestNode { + private readonly Func _predicate; + private readonly object _userObject; + private readonly List _queryNodes = new List(); + + + public RequestWhenNode(Func predicate, object userObject) { + _predicate = predicate; + _userObject = userObject; + } + + + public RequestQueryNode Add( + List> queryParameters, + List queryStringAssertions) + { + var existingQuery = _queryNodes.SingleOrDefault(node => node.Matches(queryParameters)); + + if (existingQuery == null) + { + existingQuery = new RequestQueryNode(queryParameters, queryStringAssertions); + _queryNodes.Add(existingQuery); + } + + return existingQuery; + } + + public RequestQueryNode? Match(string queryString) + { + return _queryNodes.SingleOrDefault(node => node.Matches(queryString)); + } + + public bool Matches(object? userObject) { + if (userObject == null) + return false; + return _userObject == userObject; + } + + public bool Matches(HttpRequestMessage message) { + return _predicate(message, _userObject); + } + + public override void Accept(RequestNodeVisitor visitor) + { + visitor.When(_predicate); + + foreach (var node in _queryNodes) + { + node.Accept(visitor); + } + } + + + } +} \ No newline at end of file diff --git a/test/Codenizer.HttpClient.Testable.Tests.Unit/WhenMatchingRoutes.cs b/test/Codenizer.HttpClient.Testable.Tests.Unit/WhenMatchingRoutes.cs index b0b0d56..6d5ea6e 100644 --- a/test/Codenizer.HttpClient.Testable.Tests.Unit/WhenMatchingRoutes.cs +++ b/test/Codenizer.HttpClient.Testable.Tests.Unit/WhenMatchingRoutes.cs @@ -43,6 +43,51 @@ public void GivenPathWithQueryParameters_ReturnedRequestBuilderMatches() .Should() .Be(requestBuilder); } + + + [Fact] + public void GivenPathWithQueryParametersAndWhen_ReturnedRequestBuilderMatches() + { + var requestBuilder = new RequestBuilder(HttpMethod.Get, "/api/foo/bar?blah=blurb", null); + requestBuilder.AndWhen(new { }, (message, o) => true); + + var routes = new List + { + requestBuilder + }; + + var dictionary = ConfiguredRequests.FromRequestBuilders(routes); + + dictionary + .Match( + HttpMethod.Get, + "/api/foo/bar?blah=blurb", + null) + .Should() + .Be(requestBuilder); + } + + [Fact] + public void GivenPathWithQueryParametersAndWhen_ReturnedRequestBuilderNoMatch() + { + var requestBuilder = new RequestBuilder(HttpMethod.Get, "/api/foo/bar?blah=blurb", null); + requestBuilder.AndWhen(new { }, (message, o) => true); + + var routes = new List + { + requestBuilder + }; + + var dictionary = ConfiguredRequests.FromRequestBuilders(routes); + + dictionary + .Match( + HttpMethod.Get, + "/api/foo/bar?blah=blurb", + null) + .Should() + .Be(requestBuilder); + } [Fact] public void GivenNonConfigured_NoResultIsReturned()