Skip to content

Commit 1115873

Browse files
authored
Support URI Patterns parser everywhere (#192)
1 parent 9bccf95 commit 1115873

32 files changed

+697
-509
lines changed

CHANGELOG.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,26 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
55
this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
66

77
## [0.10] - unplanned
8+
### Deprecated
9+
- `ShouldHaveMadeRequestsTo(this TestableHttpMessageHandler, string, bool)` and `ShouldHaveMadeRequestsTo(this TestableHttpMessageHandler, string, bool, int)` have been deprecated. CaseInsensitivity is controlled by the `UriPatternMatchingOptions` that can be set on the `TestableHttpMessageHandler`.
10+
- `WithRequestUri(this IHttpRequestMessagesCheck, string, bool)` and `WithRequestUri(this IHttpRequestMessagesCheck, string, bool, int)` have been deprecated. CaseInsensitivity is controlled by the `UriPatternMatchingOptions` that can be set on the `TestableHttpMessageHandler`.
11+
- `WithQueryString` has been deprecated, since `ShouldHaveMadeRequestTo` and `WithRequestUri` now properly support querystrings.
12+
813
### Removed
914
- `TestableHttpMessageHandler.SimulateTimeout` has been removed, and can be replaced with `RespondWith(Responses.Timeout())`.
1015
- `TestableHttpMessageHandler.RespondWith(Func<HttpRequestMessage, HttpResponseMessage>)` has been removed, it's functionality is replaced by IResponse.
1116
- `RespondWith(this TestableHttpMessageHandler, HttpResponseMessage)` has been removed, the response is modified with every call, so it doesn't work reliably and is different from how HttpClientHandler works, which creates a HttpResponseMessage for every request.
1217
- `HttpResponseMessageBuilder` and `RespondWith(this TestableHttpMessageHandler, HttpResponseMessageBuilder)` has been removed, it's functionality can be replaced with ConfiguredResponse or a custom IResponse.
1318

19+
### Added
20+
- URI patterns now support query parameters and by default will use the unescaped values, note that the order is still important.
21+
- URI pattern parsing is extended to be able to parse most URI's.
22+
23+
### Changed
24+
- Use the same parser for the assertion methods `WithRequestUri` (which is used by `ShouldHaveMadeRequestsTo`) as for the RoutingResponse functionality.
25+
- `RouteParserException` has been renamed to `UriPatternParserException`.
26+
- Renamed `RoutingOptions` to `UriPatternMatchingOptions`.
27+
1428
## [0.9] - 2022-11-25
1529
### Deprecated
1630
- `Responses.NoContent()` has been deprecated, since it doesn't fit well with the rest of the API. Please use `Responses.StatusCode(HttpStatusCode.NoContent)` instead.

README.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,46 @@ testHandler.ShouldHaveMadeRequestsTo("https://httpbin.org/*");
3636

3737
More examples can be found in the [IntegrationTests project](test/TestableHttpClient.IntegrationTests)
3838

39+
## URI Patterns
40+
41+
TestableHttpClient supports URI patterns in several places, mainly in:
42+
- Response routing, where an URI pattern is used for matching the request URI to a response
43+
- Assertions, where requests can be asserted against an URI pattern.
44+
45+
URI patterns are based on URI's as specified in [RFC 3986](https://www.rfc-editor.org/rfc/rfc3986), but allow the wildcard character `*` to specify optional parts of an URI.
46+
47+
An URI contains several components:
48+
- The scheme of an URI is optional, but when given it should end with `://`. When not given `*://` is assumed.
49+
- User Information (`username:password@`) is ignored and is not checked at all.
50+
- The host is optional and when not given `*` is assumed. Both IP addresses and registered names are supported.
51+
- The port is optional, but when ':' is provided after host, it should have a value.
52+
- The path is optional, but should start with a `/`. When `/` is given, it can be followed by a `*` to match it with any path.
53+
- Query parameters are optional, when given it should start with a `?`.
54+
- Fragments are ignored, but should start with a `#`.
55+
56+
URI patterns differ from URI's in the following ways:
57+
- Any character is allowed, for example: `myhost:myport` is a valid URI pattern, but not a valid URI. (and this will never match).
58+
- No encoding is performed, and patterns are matched against the unescaped values of an URI.
59+
- Patterns are not normalized, so a path pattern `/test/../example` will not work.
60+
61+
Some examples:
62+
63+
Uri pattern | Matches
64+
------------|--------
65+
*|Matches any URL
66+
\*://\*/\*?\* | Matches any URL
67+
/get | Matches any URL that uses the path `/get`
68+
http*://* | Matches any URL that uses the scheme `http` or `https` (or any other scheme that starts with `http`)
69+
localhost:5000 | Matches any URL that uses localhost for the host and port 5000, no matter what scheme or path is used.
70+
71+
## Controlling the behaviour of pattern matching
72+
73+
TestableHttpClient has several options available that let you control different parts of the library. These options can be found on
74+
the `TestableHttpMessageHandler.Options` and are passed to the `HttpRepsonseContext` and the `IHttpRequestMessagesCheck`.
75+
The options include:
76+
- `JsonSerializerOptions` for controlling the serialization of json content
77+
- `UriPatternMatchingOptions` for controlling how the URI pattern matching works.
78+
3979
## Supported .NET versions
4080

4181
TestableHttpClient is build as a netstandard2.0 library, so theoretically it can work on every .NET version that support netstandard2.0.

src/TestableHttpClient/HttpRequestMessageExtensions.cs

Lines changed: 1 addition & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -199,33 +199,6 @@ internal static bool HasContentHeader(this HttpRequestMessage httpRequestMessage
199199
return httpRequestMessage.Content.Headers.HasHeader(headerName, headerValue);
200200
}
201201

202-
/// <summary>
203-
/// Determines whether the request uri matches a pattern.
204-
/// </summary>
205-
/// <param name="httpRequestMessage">A <see cref="HttpRequestMessage"/> to check the correct uri on.</param>
206-
/// <param name="pattern">A pattern to match with the request uri, supports * as wildcards.</param>
207-
/// <returns>true when the request uri matches the pattern; otherwise, false.</returns>
208-
internal static bool HasMatchingUri(this HttpRequestMessage httpRequestMessage, string pattern, bool ignoreCase = true)
209-
{
210-
if (httpRequestMessage == null)
211-
{
212-
throw new ArgumentNullException(nameof(httpRequestMessage));
213-
}
214-
215-
if (httpRequestMessage.RequestUri == null)
216-
{
217-
return false;
218-
}
219-
220-
return pattern switch
221-
{
222-
null => throw new ArgumentNullException(nameof(pattern)),
223-
"" => false,
224-
"*" => true,
225-
_ => StringMatcher.Matches(httpRequestMessage.RequestUri.AbsoluteUri, pattern, ignoreCase),
226-
};
227-
}
228-
229202
/// <summary>
230203
/// Determines whether the request has content.
231204
/// </summary>
@@ -280,6 +253,7 @@ internal static bool HasContent(this HttpRequestMessage httpRequestMessage, stri
280253
/// <param name="httpRequestMessage">A <see cref="HttpRequestMessage"/> to check the correct request uir querystring on.</param>
281254
/// <param name="pattern">A pattern to match the request uri querystring, supports * as wildcards.</param>
282255
/// <returns>true when the request uri querystring matches the pattern; otherwise, false.</returns>
256+
[Obsolete("Use WithRequestUri instead, since it now properly supports QueryStrings as well")]
283257
internal static bool HasQueryString(this HttpRequestMessage httpRequestMessage, string pattern)
284258
{
285259
if (httpRequestMessage == null)

src/TestableHttpClient/HttpRequestMessagesCheckExtensions.cs

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,9 @@ public static class HttpRequestMessagesCheckExtensions
88
/// <param name="check">The implementation that hold all the request messages.</param>
99
/// <param name="pattern">The uri pattern that is expected.</param>
1010
/// <returns>The <seealso cref="IHttpRequestMessagesCheck"/> for further assertions.</returns>
11-
public static IHttpRequestMessagesCheck WithRequestUri(this IHttpRequestMessagesCheck check, string pattern) => WithRequestUri(check, pattern, true, null);
11+
public static IHttpRequestMessagesCheck WithRequestUri(this IHttpRequestMessagesCheck check, string pattern) => WithRequestUri(check, pattern, null);
1212

13+
[Obsolete("Please use an overload without the 'ignoreCase', since ignoring casing is now controlled globally.")]
1314
public static IHttpRequestMessagesCheck WithRequestUri(this IHttpRequestMessagesCheck check, string pattern, bool ignoreCase) => WithRequestUri(check, pattern, ignoreCase, null);
1415

1516
/// <summary>
@@ -19,9 +20,35 @@ public static class HttpRequestMessagesCheckExtensions
1920
/// <param name="pattern">The uri pattern that is expected.</param>
2021
/// <param name="expectedNumberOfRequests">The expected number of requests.</param>
2122
/// <returns>The <seealso cref="IHttpRequestMessagesCheck"/> for further assertions.</returns>
22-
public static IHttpRequestMessagesCheck WithRequestUri(this IHttpRequestMessagesCheck check, string pattern, int expectedNumberOfRequests) => WithRequestUri(check, pattern, true, (int?)expectedNumberOfRequests);
23+
public static IHttpRequestMessagesCheck WithRequestUri(this IHttpRequestMessagesCheck check, string pattern, int expectedNumberOfRequests) => WithRequestUri(check, pattern, (int?)expectedNumberOfRequests);
24+
25+
[Obsolete("Please use an overload without the 'ignoreCase', since ignoring casing is now controlled globally.")]
2326
public static IHttpRequestMessagesCheck WithRequestUri(this IHttpRequestMessagesCheck check, string pattern, bool ignoreCase, int expectedNumberOfRequests) => WithRequestUri(check, pattern, ignoreCase, (int?)expectedNumberOfRequests);
2427

28+
private static IHttpRequestMessagesCheck WithRequestUri(this IHttpRequestMessagesCheck check, string pattern, int? expectedNumberOfRequests)
29+
{
30+
if (check == null)
31+
{
32+
throw new ArgumentNullException(nameof(check));
33+
}
34+
35+
if (string.IsNullOrEmpty(pattern))
36+
{
37+
throw new ArgumentNullException(nameof(pattern));
38+
}
39+
40+
var condition = string.Empty;
41+
if (pattern != "*")
42+
{
43+
condition = $"uri pattern '{pattern}'";
44+
}
45+
46+
UriPattern uriPattern = UriPatternParser.Parse(pattern);
47+
48+
return check.WithFilter(x => x.RequestUri is not null && uriPattern.Matches(x.RequestUri, check.Options.UriPatternMatchingOptions), expectedNumberOfRequests, condition);
49+
}
50+
51+
[Obsolete("Please use an overload without the 'ignoreCase', since ignoring casing is now controlled globally.")]
2552
private static IHttpRequestMessagesCheck WithRequestUri(this IHttpRequestMessagesCheck check, string pattern, bool ignoreCase, int? expectedNumberOfRequests)
2653
{
2754
if (check == null)
@@ -40,7 +67,16 @@ private static IHttpRequestMessagesCheck WithRequestUri(this IHttpRequestMessage
4067
condition = $"uri pattern '{pattern}'";
4168
}
4269

43-
return check.WithFilter(x => x.HasMatchingUri(pattern, ignoreCase), expectedNumberOfRequests, condition);
70+
UriPattern uriPattern = UriPatternParser.Parse(pattern);
71+
var options = new UriPatternMatchingOptions
72+
{
73+
HostCaseInsensitive = ignoreCase,
74+
PathCaseInsensitive = ignoreCase,
75+
SchemeCaseInsensitive = ignoreCase,
76+
QueryCaseInsensitive = ignoreCase
77+
};
78+
79+
return check.WithFilter(x => x.RequestUri is not null && uriPattern.Matches(x.RequestUri, options), expectedNumberOfRequests, condition);
4480
}
4581

4682
/// <summary>
@@ -49,6 +85,7 @@ private static IHttpRequestMessagesCheck WithRequestUri(this IHttpRequestMessage
4985
/// <param name="check">The implementation that hold all the request messages.</param>
5086
/// <param name="pattern">The querystring pattern that is expected.</param>
5187
/// <returns>The <seealso cref="IHttpRequestMessagesCheck"/> for further assertions.</returns>
88+
[Obsolete("Use WithRequestUri instead, since it now properly supports QueryStrings as well")]
5289
public static IHttpRequestMessagesCheck WithQueryString(this IHttpRequestMessagesCheck check, string pattern) => WithQueryString(check, pattern, null);
5390

5491
/// <summary>
@@ -58,8 +95,10 @@ private static IHttpRequestMessagesCheck WithRequestUri(this IHttpRequestMessage
5895
/// <param name="pattern">The querystring pattern that is expected.</param>
5996
/// <param name="expectedNumberOfRequests">The expected number of requests.</param>
6097
/// <returns>The <seealso cref="IHttpRequestMessagesCheck"/> for further assertions.</returns>
98+
[Obsolete("Use WithRequestUri instead, since it now properly supports QueryStrings as well")]
6199
public static IHttpRequestMessagesCheck WithQueryString(this IHttpRequestMessagesCheck check, string pattern, int expectedNumberOfRequests) => WithQueryString(check, pattern, (int?)expectedNumberOfRequests);
62100

101+
[Obsolete("Use WithRequestUri instead, since it now properly supports QueryStrings as well")]
63102
private static IHttpRequestMessagesCheck WithQueryString(this IHttpRequestMessagesCheck check, string pattern, int? expectedNumberOfRequests)
64103
{
65104
if (check == null)

src/TestableHttpClient/PublicAPI.Shipped.txt

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ static TestableHttpClient.HttpRequestMessagesCheckExtensions.WithRequestUri(this
103103
static TestableHttpClient.HttpRequestMessagesCheckExtensions.WithRequestUri(this TestableHttpClient.IHttpRequestMessagesCheck! check, string! pattern, bool ignoreCase, int expectedNumberOfRequests) -> TestableHttpClient.IHttpRequestMessagesCheck!
104104
static TestableHttpClient.HttpRequestMessagesCheckExtensions.WithRequestUri(this TestableHttpClient.IHttpRequestMessagesCheck! check, string! pattern, int expectedNumberOfRequests) -> TestableHttpClient.IHttpRequestMessagesCheck!
105105

106-
TestableHttpClient.Utils.RouteParserException
107-
TestableHttpClient.Utils.RouteParserException.RouteParserException() -> void
108-
TestableHttpClient.Utils.RouteParserException.RouteParserException(string! message) -> void
109-
TestableHttpClient.Utils.RouteParserException.RouteParserException(string! message, System.Exception! innerException) -> void
106+
TestableHttpClient.Utils.UriPatternParserException
107+
TestableHttpClient.Utils.UriPatternParserException.UriPatternParserException() -> void
108+
TestableHttpClient.Utils.UriPatternParserException.UriPatternParserException(string! message) -> void
109+
TestableHttpClient.Utils.UriPatternParserException.UriPatternParserException(string! message, System.Exception! innerException) -> void
Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,7 @@
1-

1+
TestableHttpClient.TestableHttpMessageHandlerOptions.UriPatternMatchingOptions.get -> TestableHttpClient.UriPatternMatchingOptions!
2+
TestableHttpClient.UriPatternMatchingOptions
3+
TestableHttpClient.UriPatternMatchingOptions.DefaultQueryFormat.get -> System.UriFormat
4+
TestableHttpClient.UriPatternMatchingOptions.DefaultQueryFormat.set -> void
5+
TestableHttpClient.UriPatternMatchingOptions.QueryCaseInsensitive.get -> bool
6+
TestableHttpClient.UriPatternMatchingOptions.QueryCaseInsensitive.set -> void
7+
TestableHttpClient.UriPatternMatchingOptions.UriPatternMatchingOptions() -> void

src/TestableHttpClient/Response/RoutingResponse.cs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,18 +10,18 @@ public Task ExecuteAsync(HttpResponseContext context, CancellationToken cancella
1010

1111
if (context.HttpRequestMessage.RequestUri is not null)
1212
{
13-
response = GetResponseForRequest(context.HttpRequestMessage.RequestUri, context.Options.RoutingOptions);
13+
response = GetResponseForRequest(context.HttpRequestMessage.RequestUri, context.Options.UriPatternMatchingOptions);
1414
}
1515

1616
return response.ExecuteAsync(context, cancellationToken);
1717
}
1818

19-
public Dictionary<RouteDefinition, IResponse> ResponseMap { get; init; } = new();
19+
public Dictionary<UriPattern, IResponse> ResponseMap { get; init; } = new();
2020
public IResponse FallBackResponse { get; internal set; } = StatusCode(NotFound);
2121

22-
private IResponse GetResponseForRequest(Uri requestUri, RoutingOptions routingOptions)
22+
private IResponse GetResponseForRequest(Uri requestUri, UriPatternMatchingOptions uriPatternOptions)
2323
{
24-
var matchingResponse = ResponseMap.FirstOrDefault(x => x.Key.Matches(requestUri, routingOptions));
24+
var matchingResponse = ResponseMap.FirstOrDefault(x => x.Key.Matches(requestUri, uriPatternOptions));
2525

2626
return matchingResponse.Value switch
2727
{
Lines changed: 6 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
1-
namespace TestableHttpClient.Response;
1+
using System.Collections.Concurrent;
2+
3+
namespace TestableHttpClient.Response;
24

35
internal class SequencedResponse : IResponse
46
{
5-
private readonly Queue<IResponse> responses;
7+
private readonly ConcurrentQueue<IResponse> responses;
68
private readonly IResponse _lastResponse;
79
public SequencedResponse(IEnumerable<IResponse> responses)
810
{
911
this.responses = new(responses ?? throw new ArgumentNullException(nameof(responses)));
10-
if (this.responses.Count == 0)
12+
if (this.responses.IsEmpty)
1113
{
1214
throw new ArgumentException("Responses can't be empty.", nameof(responses));
1315
}
@@ -22,13 +24,6 @@ public Task ExecuteAsync(HttpResponseContext context, CancellationToken cancella
2224

2325
private IResponse GetResponse()
2426
{
25-
if (responses.Any())
26-
{
27-
return responses.Dequeue();
28-
}
29-
else
30-
{
31-
return _lastResponse;
32-
}
27+
return responses.TryDequeue(out var response) ? response : _lastResponse;
3328
}
3429
}

src/TestableHttpClient/RoutingOptions.cs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,19 @@
33
/// <summary>
44
/// Options specific for routing.
55
/// </summary>
6+
[Obsolete("Renamed to UriPatternMatchingOptions")]
67
public class RoutingOptions
78
{
89
/// <summary>
9-
/// Indeicates whether or not the scheme of a route should be treated as case insensitive. Default: true
10+
/// Indicates whether or not the scheme of an URI should be treated as case insensitive. Default: true
1011
/// </summary>
1112
public bool SchemeCaseInsensitive { get; set; } = true;
1213
/// <summary>
13-
/// Indeicates whether or not the host of a route should be treated as case insensitive. Default: true
14+
/// Indicates whether or not the host of an URI should be treated as case insensitive. Default: true
1415
/// </summary>
1516
public bool HostCaseInsensitive { get; set; } = true;
1617
/// <summary>
17-
/// Indeicates whether or not the path of a route should be treated as case insensitive. Default: true
18+
/// Indicates whether or not the path of an URI should be treated as case insensitive. Default: true
1819
/// </summary>
1920
public bool PathCaseInsensitive { get; set; } = true;
2021
}

0 commit comments

Comments
 (0)