Skip to content

Commit 94b460b

Browse files
committed
Initial version of HttpRequestMessagePattern.
1 parent 0dbca5a commit 94b460b

File tree

7 files changed

+264
-71
lines changed

7 files changed

+264
-71
lines changed

src/TestableHttpClient/UriPatternMatchingOptions.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
/// <summary>
44
/// Options specific for URI pattern matching.
55
/// </summary>
6-
public class UriPatternMatchingOptions
6+
public sealed class UriPatternMatchingOptions
77
{
88
/// <summary>
99
/// Indicates whether or not the scheme of an URI should be treated as case insensitive. Default: true
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
namespace TestableHttpClient.Utils;
2+
3+
internal sealed class HttpRequestMessagePattern
4+
{
5+
public Value<HttpMethod> Method { get; init; } = Value.Any<HttpMethod>();
6+
public UriPattern RequestUri { get; init; } = UriPattern.Any;
7+
public Value<Version> Version { get; init; } = Value.Any<Version>();
8+
9+
// public ??? Headers { get; init; }
10+
public Value<string> Content { get; init; } = Value.Any<string>();
11+
12+
public HttpRequestMessagePatternMatchingResult Matches(HttpRequestMessage httpRequestMessage, HttpRequestMessagePatternMatchingOptions options) =>
13+
new()
14+
{
15+
Method = Method.Matches(httpRequestMessage.Method, false),
16+
RequestUri = RequestUri.Matches(httpRequestMessage.RequestUri, options.RequestUriMatchingOptions),
17+
Version = Version.Matches(httpRequestMessage.Version, false),
18+
Content = MatchesContent(httpRequestMessage.Content)
19+
};
20+
21+
private bool MatchesContent(HttpContent? requestContent)
22+
{
23+
if (requestContent == null)
24+
{
25+
return Content == Value.Any<string>();
26+
}
27+
28+
var contentValue = requestContent.ReadAsStringAsync().Result;
29+
30+
return Content.Matches(contentValue, false);
31+
}
32+
}
33+
34+
internal sealed class HttpRequestMessagePatternMatchingOptions
35+
{
36+
public UriPatternMatchingOptions RequestUriMatchingOptions { get; init; } = new();
37+
}
38+
39+
internal sealed class HttpRequestMessagePatternMatchingResult
40+
{
41+
public bool Method { get; init; }
42+
public bool RequestUri { get; init; }
43+
public bool Version { get; init; }
44+
public bool Content { get; init; }
45+
}
46+

src/TestableHttpClient/Utils/RoutingResponseBuilder.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
namespace TestableHttpClient.Utils;
44

5-
internal class RoutingResponseBuilder : IRoutingResponseBuilder
5+
internal sealed class RoutingResponseBuilder : IRoutingResponseBuilder
66
{
77
public RoutingResponse RoutingResponse { get; } = new();
88

src/TestableHttpClient/Utils/UriPattern.cs

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,19 @@
22

33
namespace TestableHttpClient.Utils;
44

5-
internal class UriPattern
5+
internal sealed class UriPattern
66
{
77
public static UriPattern Any { get; } = new UriPattern();
88

9-
public Value Scheme { get; init; } = Value.Any();
10-
public Value Host { get; init; } = Value.Any();
11-
public Value Port { get; init; } = Value.Any();
12-
public Value Path { get; init; } = Value.Any();
13-
public Value Query { get; init; } = Value.Any();
9+
public Value<string> Scheme { get; init; } = Value.Any();
10+
public Value<string> Host { get; init; } = Value.Any();
11+
public Value<string> Port { get; init; } = Value.Any();
12+
public Value<string> Path { get; init; } = Value.Any();
13+
public Value<string> Query { get; init; } = Value.Any();
1414

15-
public bool Matches(Uri requestUri, UriPatternMatchingOptions options) =>
15+
public bool Matches(Uri? requestUri, UriPatternMatchingOptions options) =>
16+
(requestUri is null && this == Any) ||
17+
requestUri is not null &&
1618
Scheme.Matches(requestUri.Scheme, options.SchemeCaseInsensitive) &&
1719
Host.Matches(requestUri.Host, options.HostCaseInsensitive) &&
1820
Port.Matches(requestUri.Port.ToString(CultureInfo.InvariantCulture), true) &&

src/TestableHttpClient/Utils/UriPatternParser.cs

Lines changed: 35 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -75,64 +75,49 @@ private static UriPattern ParsePattern(ReadOnlySpan<char> patternSpan)
7575
Query = ParseQuery(queryPattern)
7676
};
7777

78-
static Value ParseScheme(ReadOnlySpan<char> scheme)
78+
static Value<string> ParseScheme(ReadOnlySpan<char> scheme) => scheme switch
7979
{
80-
return scheme switch
81-
{
82-
[] => Value.Any(),
83-
['*'] => Value.Any(),
84-
_ when scheme.IndexOf('*') != -1 => Value.Pattern(scheme.ToString()),
85-
_ => Value.Exact(scheme.ToString())
86-
};
87-
}
80+
[] => Value.Any(),
81+
['*'] => Value.Any(),
82+
_ when scheme.IndexOf('*') != -1 => Value.Pattern(scheme.ToString()),
83+
_ => Value.Exact(scheme.ToString())
84+
};
8885

89-
static Value ParseHost(ReadOnlySpan<char> host)
86+
static Value<string> ParseHost(ReadOnlySpan<char> host) => host switch
9087
{
91-
return host switch
92-
{
93-
[] => Value.Any(),
94-
['*'] => Value.Any(),
95-
_ when host.IndexOf('*') != -1 => Value.Pattern(host.ToString()),
96-
_ => Value.Exact(host.ToString())
97-
};
98-
}
88+
[] => Value.Any(),
89+
['*'] => Value.Any(),
90+
_ when host.IndexOf('*') != -1 => Value.Pattern(host.ToString()),
91+
_ => Value.Exact(host.ToString())
92+
};
9993

100-
static Value ParsePort(ReadOnlySpan<char> port)
94+
static Value<string> ParsePort(ReadOnlySpan<char> port) => port switch
10195
{
102-
return port switch
103-
{
104-
[] => Value.Any(),
105-
[':'] => throw new UriPatternParserException("Invalid port"),
106-
[':', '*'] => Value.Any(),
107-
[':', .. var rest] when rest.IndexOf('*') != -1 => Value.Pattern(rest.ToString()),
108-
[':', .. var rest] => Value.Exact(rest.ToString()),
109-
_ => throw new UnreachableException()
110-
};
111-
}
96+
[] => Value.Any(),
97+
[':'] => throw new UriPatternParserException("Invalid port"),
98+
[':', '*'] => Value.Any(),
99+
[':', .. var rest] when rest.IndexOf('*') != -1 => Value.Pattern(rest.ToString()),
100+
[':', .. var rest] => Value.Exact(rest.ToString()),
101+
_ => throw new UnreachableException()
102+
};
112103

113-
static Value ParsePath(ReadOnlySpan<char> path)
104+
static Value<string> ParsePath(ReadOnlySpan<char> path) => path switch
114105
{
115-
return path switch
116-
{
117-
[] => Value.Any(),
118-
['/', '*'] => Value.Any(),
119-
['/', .. var rest] when rest.IndexOf('*') != -1 => Value.Pattern(path.ToString()),
120-
['/', ..] => Value.Exact(path.ToString()),
121-
_ => throw new UnreachableException()
122-
};
123-
}
106+
[] => Value.Any(),
107+
['/', '*'] => Value.Any(),
108+
['/', .. var rest] when rest.IndexOf('*') != -1 => Value.Pattern(path.ToString()),
109+
['/', ..] => Value.Exact(path.ToString()),
110+
_ => throw new UnreachableException()
111+
};
124112

125-
static Value ParseQuery(ReadOnlySpan<char> query)
113+
static Value<string> ParseQuery(ReadOnlySpan<char> query) => query switch
126114
{
127-
return query switch
128-
{
129-
[] => Value.Any(),
130-
['?'] => Value.Any(),
131-
['?', '*'] => Value.Any(),
132-
['?', .. var rest] when rest.IndexOf('*') != -1 => Value.Pattern(rest.ToString()),
133-
['?', .. var rest] => Value.Exact(rest.ToString()),
134-
_ => throw new UnreachableException()
135-
};
136-
}
115+
[] => Value.Any(),
116+
['?'] => Value.Any(),
117+
['?', '*'] => Value.Any(),
118+
['?', .. var rest] when rest.IndexOf('*') != -1 => Value.Pattern(rest.ToString()),
119+
['?', .. var rest] => Value.Exact(rest.ToString()),
120+
_ => throw new UnreachableException()
121+
};
137122
}
138123
}

src/TestableHttpClient/Utils/Value.cs

Lines changed: 42 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,29 +4,59 @@ namespace TestableHttpClient.Utils;
44

55
internal abstract record Value
66
{
7-
private static readonly Value _anyValue = new AnyValue();
8-
public static Value Any() => _anyValue;
9-
public static Value Exact(string value) => new ExactValue(value);
10-
public static Value Pattern(string pattern) => new PatternValue(pattern);
11-
internal abstract bool Matches(string value, bool ignoreCase);
7+
public static Value<string> Any() => Any<string>();
8+
public static Value<T> Any<T>() => new AnyValue<T>();
9+
public static Value<T> OneOf<T>(params T[] values) => new OneOfValue<T>(values);
10+
public static Value<string> Exact(string value) => Exact<string>(value);
11+
public static Value<T> Exact<T>(T value) => new ExactValue<T>(value);
12+
public static Value<string> Pattern(string pattern) => new PatternValue(pattern);
13+
14+
}
15+
16+
internal abstract record Value<T>
17+
{
18+
internal abstract bool Matches(T value, bool ignoreCase);
1219
}
1320

1421
[DebuggerDisplay("Any value")]
15-
file sealed record AnyValue : Value
22+
file sealed record AnyValue<T> : Value<T>
1623
{
17-
internal override bool Matches(string value, bool ignoreCase) => true;
24+
internal override bool Matches(T value, bool ignoreCase) => true;
1825
}
1926

2027
[DebuggerDisplay("Exact value: {expectedValue}")]
21-
file sealed record ExactValue : Value
28+
file sealed record ExactValue<T> : Value<T>
2229
{
23-
private readonly string expectedValue;
24-
public ExactValue(string expectedValue) => this.expectedValue = expectedValue;
25-
internal override bool Matches(string value, bool ignoreCase) => expectedValue.Equals(value, ignoreCase ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal);
30+
private readonly T expectedValue;
31+
public ExactValue(T expectedValue) => this.expectedValue = expectedValue ?? throw new ArgumentNullException(nameof(expectedValue));
32+
internal override bool Matches(T value, bool ignoreCase)
33+
{
34+
if (expectedValue is string expectedStringValue && value is string stringValue)
35+
{
36+
return expectedStringValue.Equals(stringValue, ignoreCase ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal);
37+
}
38+
39+
return expectedValue!.Equals(value);
40+
}
41+
}
42+
43+
file sealed record OneOfValue<T> : Value<T>
44+
{
45+
private readonly IEnumerable<T> values;
46+
public OneOfValue(IEnumerable<T> values) => this.values = values;
47+
internal override bool Matches(T value, bool ignoreCase)
48+
{
49+
if (value is string stringValue)
50+
{
51+
return values.OfType<string>().Contains(stringValue, ignoreCase ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal);
52+
}
53+
54+
return values.Contains(value);
55+
}
2656
}
2757

2858
[DebuggerDisplay("Pattern value: {pattern}")]
29-
file sealed record PatternValue : Value
59+
file sealed record PatternValue : Value<string>
3060
{
3161
private readonly string pattern;
3262
public PatternValue(string pattern) => this.pattern = pattern;
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
using TestableHttpClient.Utils;
2+
3+
namespace TestableHttpClient.Tests.Utils;
4+
5+
public class HttpRequestMessagePatternTests
6+
{
7+
private readonly HttpRequestMessagePatternMatchingOptions defaultOptions = new();
8+
9+
[Theory]
10+
[InlineData("GET")]
11+
[InlineData("POST")]
12+
[InlineData("PATCH")]
13+
[InlineData("PUT")]
14+
[InlineData("DELETE")]
15+
[InlineData("HEAD")]
16+
[InlineData("OPTIONS")]
17+
[InlineData("TRACE")]
18+
[InlineData("CONNECT")]
19+
public void Matches_HttpRequestMessageWithAnyHttpMethod_MatchesAllHttpMethods(string httpMethod)
20+
{
21+
HttpRequestMessagePattern sut = new();
22+
23+
using HttpRequestMessage input = new(new HttpMethod(httpMethod), "https://localhost");
24+
25+
Assert.True(sut.Matches(input, defaultOptions).Method);
26+
}
27+
28+
[Theory]
29+
[InlineData("GET", true)]
30+
[InlineData("POST", true)]
31+
[InlineData("PATCH", false)]
32+
[InlineData("PUT", false)]
33+
[InlineData("DELETE", true)]
34+
[InlineData("HEAD", false)]
35+
[InlineData("OPTIONS", false)]
36+
[InlineData("TRACE", false)]
37+
[InlineData("CONNECT", false)]
38+
public void Matches_HttpRequestMessageWithSpecificHttpMethods_OnlyMatchesSpecifiedHttpMethods(string httpMethod, bool match)
39+
{
40+
HttpRequestMessagePattern sut = new()
41+
{
42+
Method = Value.OneOf(HttpMethod.Get, HttpMethod.Post, HttpMethod.Delete)
43+
};
44+
45+
using HttpRequestMessage input = new(new HttpMethod(httpMethod), "https://localhost");
46+
47+
Assert.Equal(match, sut.Matches(input, defaultOptions).Method);
48+
}
49+
50+
[Fact]
51+
public void Matches_HttpRequestMessageWithSpecificUrl_MatchesUrl()
52+
{
53+
HttpRequestMessagePattern sut = new()
54+
{
55+
RequestUri = UriPatternParser.Parse("https://localhost/test/*")
56+
};
57+
58+
using HttpRequestMessage matchingInput = new(HttpMethod.Get, "https://localhost/test/123");
59+
using HttpRequestMessage notMatchingInput = new(HttpMethod.Get, "https://localhost/something/123");
60+
61+
Assert.True(sut.Matches(matchingInput, defaultOptions).RequestUri);
62+
Assert.False(sut.Matches(notMatchingInput, defaultOptions).RequestUri);
63+
}
64+
65+
[Fact]
66+
public void Matches_HttpRequestMessageWithSpecificVersion_MatchesExactVersion()
67+
{
68+
HttpRequestMessagePattern sut = new()
69+
{
70+
Version = Value.Exact(HttpVersion.Version11)
71+
};
72+
73+
using HttpRequestMessage matchingVersion = new() { Version = HttpVersion.Version11 };
74+
using HttpRequestMessage notMatchingVersion = new() { Version = HttpVersion.Version10 };
75+
76+
Assert.True(sut.Matches(matchingVersion, defaultOptions).Version);
77+
Assert.False(sut.Matches(notMatchingVersion, defaultOptions).Version);
78+
}
79+
80+
[Fact]
81+
public void Matches_HttpRequestMessageWithoutBody_MatchesAnyBody()
82+
{
83+
HttpRequestMessagePattern sut = new()
84+
{
85+
Content = Value.Any<string>()
86+
};
87+
88+
using HttpRequestMessage matchingRequest = new();
89+
90+
Assert.True(sut.Matches(matchingRequest, defaultOptions).Content);
91+
}
92+
93+
[Theory]
94+
[InlineData("")]
95+
[InlineData("Some text")]
96+
[InlineData("{\"key\":\"value\"}")]
97+
public void Matches_HttpRequestMessageWithBody_MatchesExactBody(string content)
98+
{
99+
HttpRequestMessagePattern sut = new()
100+
{
101+
Content = Value.Exact(content)
102+
};
103+
104+
using HttpRequestMessage matchingRequest = new()
105+
{
106+
Content = new StringContent(content)
107+
};
108+
109+
Assert.True(sut.Matches(matchingRequest, defaultOptions).Content);
110+
}
111+
112+
[Theory]
113+
[InlineData("")]
114+
[InlineData("Some text")]
115+
[InlineData("{\"key\":\"value\"}")]
116+
public void Matches_HttpRequestMessageWithNotMatchingBody_DoesNotMatchExactBody(string content)
117+
{
118+
HttpRequestMessagePattern sut = new()
119+
{
120+
Content = Value.Exact(content)
121+
};
122+
123+
using HttpRequestMessage matchingRequest = new()
124+
{
125+
Content = new StringContent("Example content")
126+
};
127+
128+
Assert.False(sut.Matches(matchingRequest, defaultOptions).Content);
129+
}
130+
}

0 commit comments

Comments
 (0)