diff --git a/Directory.Build.props b/Directory.Build.props
index 42859fe9..b4dd02c9 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -24,7 +24,7 @@
git
$(PackageProjectUrl).git
True
- 1.1.1
+ 1.2.0
diff --git a/appveyor.yml b/appveyor.yml
index 4da72d4d..aa6d2fc9 100644
--- a/appveyor.yml
+++ b/appveyor.yml
@@ -1,5 +1,5 @@
os: Visual Studio 2017
-version: 1.1.1.{build}
+version: 1.2.0.{build}
environment:
DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true
diff --git a/samples/SampleApp.Tests/SampleApp.Tests.csproj b/samples/SampleApp.Tests/SampleApp.Tests.csproj
index 37f12765..acd03135 100644
--- a/samples/SampleApp.Tests/SampleApp.Tests.csproj
+++ b/samples/SampleApp.Tests/SampleApp.Tests.csproj
@@ -1,6 +1,5 @@
- latest
netcoreapp2.0
@@ -9,7 +8,7 @@
-
+
diff --git a/samples/SampleApp/SampleApp.csproj b/samples/SampleApp/SampleApp.csproj
index d9f48e0e..103885fa 100644
--- a/samples/SampleApp/SampleApp.csproj
+++ b/samples/SampleApp/SampleApp.csproj
@@ -1,6 +1,5 @@
- latest
netcoreapp2.0
diff --git a/src/HttpClientInterception/HttpClientInterceptorOptions.cs b/src/HttpClientInterception/HttpClientInterceptorOptions.cs
index cc050a1e..628697f7 100644
--- a/src/HttpClientInterception/HttpClientInterceptorOptions.cs
+++ b/src/HttpClientInterception/HttpClientInterceptorOptions.cs
@@ -4,12 +4,15 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
+using System.Globalization;
using System.IO;
+using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Threading;
using System.Threading.Tasks;
+using JustEat.HttpClientInterception.Matching;
namespace JustEat.HttpClientInterception
{
@@ -24,9 +27,9 @@ public class HttpClientInterceptorOptions
internal const string JsonMediaType = "application/json";
///
- /// The to use to key registrations. This field is read-only.
+ /// The to use for key registrations.
///
- private readonly StringComparer _comparer;
+ private StringComparer _comparer;
///
/// The mapped HTTP request interceptors.
@@ -47,13 +50,19 @@ public HttpClientInterceptorOptions()
///
/// Initializes a new instance of the class.
///
- /// Whether registered URIs paths and queries are case-sensitive.
+ /// Whether registered URIs' paths and queries are case-sensitive.
public HttpClientInterceptorOptions(bool caseSensitive)
{
_comparer = caseSensitive ? StringComparer.Ordinal : StringComparer.OrdinalIgnoreCase;
_mappings = new ConcurrentDictionary(_comparer);
}
+ ///
+ /// Gets or sets an optional delegate to invoke when an HTTP request does not match an existing
+ /// registration, which optionally returns an to use.
+ ///
+ public Func> OnMissingRegistration { get; set; }
+
///
/// Gets or sets an optional delegate to invoke when an HTTP request is sent.
///
@@ -99,6 +108,7 @@ public HttpClientInterceptorOptions Clone()
ThrowOnMissingRegistration = ThrowOnMissingRegistration
};
+ clone._comparer = _comparer;
clone._mappings = new ConcurrentDictionary(_mappings, _comparer);
return clone;
@@ -127,7 +137,43 @@ public HttpClientInterceptorOptions Deregister(HttpMethod method, Uri uri)
throw new ArgumentNullException(nameof(uri));
}
- string key = BuildKey(method, uri);
+ var interceptor = new HttpInterceptionResponse()
+ {
+ Method = method,
+ RequestUri = uri,
+ };
+
+ string key = BuildKey(interceptor);
+ _mappings.Remove(key);
+
+ return this;
+ }
+
+ ///
+ /// Deregisters an existing HTTP request interception, if it exists.
+ ///
+ /// The HTTP interception to deregister.
+ ///
+ /// The current .
+ ///
+ ///
+ /// is .
+ ///
+ ///
+ /// If has been reconfigured since it was used
+ /// to register a previous HTTP request interception it will not remove that
+ /// registration. In such cases, use .
+ ///
+ public HttpClientInterceptorOptions Deregister(HttpRequestInterceptionBuilder builder)
+ {
+ if (builder == null)
+ {
+ throw new ArgumentNullException(nameof(builder));
+ }
+
+ HttpInterceptionResponse interceptor = builder.Build();
+
+ string key = BuildKey(interceptor);
_mappings.Remove(key);
return this;
@@ -187,8 +233,7 @@ public HttpClientInterceptorOptions Register(
StatusCode = statusCode
};
- string key = BuildKey(method, uri);
- _mappings[key] = interceptor;
+ ConfigureMatcherAndRegister(interceptor);
return this;
}
@@ -247,8 +292,7 @@ public HttpClientInterceptorOptions Register(
StatusCode = statusCode
};
- string key = BuildKey(method, uri);
- _mappings[key] = interceptor;
+ ConfigureMatcherAndRegister(interceptor);
return this;
}
@@ -272,8 +316,7 @@ public HttpClientInterceptorOptions Register(HttpRequestInterceptionBuilder buil
HttpInterceptionResponse interceptor = builder.Build();
- string key = BuildKey(interceptor.Method, interceptor.RequestUri, interceptor.IgnoreQuery);
- _mappings[key] = interceptor;
+ ConfigureMatcherAndRegister(interceptor);
return this;
}
@@ -296,74 +339,17 @@ public async virtual Task GetResponseAsync(HttpRequestMessa
throw new ArgumentNullException(nameof(request));
}
- string key = BuildKey(request.Method, request.RequestUri, ignoreQueryString: false);
-
- if (!_mappings.TryGetValue(key, out HttpInterceptionResponse options))
- {
- string keyWithoutQueryString = BuildKey(request.Method, request.RequestUri, ignoreQueryString: true);
-
- if (!_mappings.TryGetValue(keyWithoutQueryString, out options))
- {
- return null;
- }
- }
-
- if (options.OnIntercepted != null && !await options.OnIntercepted(request))
+ if (!TryGetResponse(request, out HttpInterceptionResponse response))
{
return null;
}
- var result = new HttpResponseMessage(options.StatusCode);
-
- try
- {
- result.RequestMessage = request;
-
- if (options.ReasonPhrase != null)
- {
- result.ReasonPhrase = options.ReasonPhrase;
- }
-
- if (options.Version != null)
- {
- result.Version = options.Version;
- }
-
- if (options.ContentStream != null)
- {
- result.Content = new StreamContent(await options.ContentStream() ?? Stream.Null);
- }
- else
- {
- byte[] content = await options.ContentFactory() ?? Array.Empty();
- result.Content = new ByteArrayContent(content);
- }
-
- if (options.ContentHeaders != null)
- {
- foreach (var pair in options.ContentHeaders)
- {
- result.Content.Headers.Add(pair.Key, pair.Value);
- }
- }
-
- result.Content.Headers.ContentType = new MediaTypeHeaderValue(options.ContentMediaType);
-
- if (options.ResponseHeaders != null)
- {
- foreach (var pair in options.ResponseHeaders)
- {
- result.Headers.Add(pair.Key, pair.Value);
- }
- }
- }
- catch (Exception)
+ if (response.OnIntercepted != null && !await response.OnIntercepted(request))
{
- result.Dispose();
- throw;
+ return null;
}
- return result;
+ return await BuildResponseAsync(request, response);
}
///
@@ -388,26 +374,127 @@ public virtual HttpClient CreateHttpClient(HttpMessageHandler innerHandler = nul
}
///
- /// Builds the mapping key to use for the specified HTTP request.
+ /// Builds the mapping key to use for the specified intercepted HTTP request.
///
- /// The HTTP method.
- /// The HTTP request URI.
- /// If true create a key without any query string but with an extra string to disambiguate.
+ /// The configured HTTP interceptor.
///
/// A to use as the key for the interceptor registration.
///
- private static string BuildKey(HttpMethod method, Uri uri, bool ignoreQueryString = false)
+ private static string BuildKey(HttpInterceptionResponse interceptor)
{
- if (ignoreQueryString)
+ if (interceptor.UserMatcher != null)
{
- var uriWithoutQueryString = uri == null ? null : new UriBuilder(uri) { Query = string.Empty }.Uri;
+ // Use the internal matcher's hash code as UserMatcher (a delegate)
+ // will always return the hash code. See https://stackoverflow.com/q/6624151/1064169
+ return $"CUSTOM:{interceptor.InternalMatcher.GetHashCode().ToString(CultureInfo.InvariantCulture)}";
+ }
+
+ var builderForKey = new UriBuilder(interceptor.RequestUri);
+ string keyPrefix = string.Empty;
- return $"{method.Method}:IGNOREQUERY:{uriWithoutQueryString}";
+ if (interceptor.IgnoreHost)
+ {
+ builderForKey.Host = "*";
+ keyPrefix = "IGNOREHOST;";
+ }
+
+ if (interceptor.IgnorePath)
+ {
+ builderForKey.Path = string.Empty;
+ keyPrefix += "IGNOREPATH;";
+ }
+
+ if (interceptor.IgnoreQuery)
+ {
+ builderForKey.Query = string.Empty;
+ keyPrefix += "IGNOREQUERY;";
+ }
+
+ return $"{keyPrefix};{interceptor.Method.Method}:{builderForKey}";
+ }
+
+ private static void PopulateHeaders(HttpHeaders headers, IEnumerable>> values)
+ {
+ if (values != null)
+ {
+ foreach (var pair in values)
+ {
+ headers.Add(pair.Key, pair.Value);
+ }
+ }
+ }
+
+ private bool TryGetResponse(HttpRequestMessage request, out HttpInterceptionResponse response)
+ {
+ response = _mappings.Values
+ .OrderByDescending((p) => p.Priority.HasValue)
+ .ThenBy((p) => p.Priority)
+ .Where((p) => p.InternalMatcher.IsMatch(request))
+ .FirstOrDefault();
+
+ return response != null;
+ }
+
+ private void ConfigureMatcherAndRegister(HttpInterceptionResponse registration)
+ {
+ RequestMatcher matcher;
+
+ if (registration.UserMatcher != null)
+ {
+ matcher = new DelegatingMatcher(registration.UserMatcher);
}
else
{
- return $"{method.Method}:{uri}";
+ matcher = new RegistrationMatcher(registration, _comparer);
}
+
+ registration.InternalMatcher = matcher;
+
+ string key = BuildKey(registration);
+ _mappings[key] = registration;
+ }
+
+ private async Task BuildResponseAsync(HttpRequestMessage request, HttpInterceptionResponse response)
+ {
+ var result = new HttpResponseMessage(response.StatusCode);
+
+ try
+ {
+ result.RequestMessage = request;
+
+ if (response.ReasonPhrase != null)
+ {
+ result.ReasonPhrase = response.ReasonPhrase;
+ }
+
+ if (response.Version != null)
+ {
+ result.Version = response.Version;
+ }
+
+ if (response.ContentStream != null)
+ {
+ result.Content = new StreamContent(await response.ContentStream() ?? Stream.Null);
+ }
+ else
+ {
+ byte[] content = await response.ContentFactory() ?? Array.Empty();
+ result.Content = new ByteArrayContent(content);
+ }
+
+ PopulateHeaders(result.Content.Headers, response.ContentHeaders);
+
+ result.Content.Headers.ContentType = new MediaTypeHeaderValue(response.ContentMediaType);
+
+ PopulateHeaders(result.Headers, response.ResponseHeaders);
+ }
+ catch (Exception)
+ {
+ result.Dispose();
+ throw;
+ }
+
+ return result;
}
private sealed class OptionsScope : IDisposable
diff --git a/src/HttpClientInterception/HttpInterceptionResponse.cs b/src/HttpClientInterception/HttpInterceptionResponse.cs
index db234638..70361d99 100644
--- a/src/HttpClientInterception/HttpInterceptionResponse.cs
+++ b/src/HttpClientInterception/HttpInterceptionResponse.cs
@@ -12,8 +12,14 @@ namespace JustEat.HttpClientInterception
{
internal sealed class HttpInterceptionResponse
{
+ internal Predicate UserMatcher { get; set; }
+
+ internal Matching.RequestMatcher InternalMatcher { get; set; }
+
internal HttpMethod Method { get; set; }
+ internal int? Priority { get; set; }
+
internal string ReasonPhrase { get; set; }
internal Uri RequestUri { get; set; }
@@ -28,6 +34,10 @@ internal sealed class HttpInterceptionResponse
internal IEnumerable>> ContentHeaders { get; set; }
+ internal bool IgnoreHost { get; set; }
+
+ internal bool IgnorePath { get; set; }
+
internal bool IgnoreQuery { get; set; }
internal IEnumerable>> ResponseHeaders { get; set; }
diff --git a/src/HttpClientInterception/HttpRequestInterceptionBuilder.cs b/src/HttpClientInterception/HttpRequestInterceptionBuilder.cs
index 43e8f0e8..9f90f2cd 100644
--- a/src/HttpClientInterception/HttpRequestInterceptionBuilder.cs
+++ b/src/HttpClientInterception/HttpRequestInterceptionBuilder.cs
@@ -33,6 +33,8 @@ public class HttpRequestInterceptionBuilder
private Func> _onIntercepted;
+ private Predicate _requestMatcher;
+
private string _reasonPhrase;
private HttpStatusCode _statusCode = HttpStatusCode.OK;
@@ -40,8 +42,46 @@ public class HttpRequestInterceptionBuilder
private UriBuilder _uriBuilder = new UriBuilder();
private Version _version;
+
+ private bool _ignoreHost;
+
+ private bool _ignorePath;
+
private bool _ignoreQuery;
+ private int? _priority;
+
+ ///
+ /// Configures the builder to match any request that meets the criteria defined by the specified predicate.
+ ///
+ ///
+ /// A delegate to a method which returns if the
+ /// request is considered a match; otherwise .
+ ///
+ ///
+ /// The current .
+ ///
+ ///
+ /// Pass a value of to remove a previously-registered custom request matching predicate.
+ ///
+ public HttpRequestInterceptionBuilder For(Predicate predicate)
+ {
+ _requestMatcher = predicate;
+ return this;
+ }
+
+ ///
+ /// Configures the builder to match any host name.
+ ///
+ ///
+ /// The current .
+ ///
+ public HttpRequestInterceptionBuilder ForAnyHost()
+ {
+ _ignoreHost = true;
+ return this;
+ }
+
///
/// Sets the HTTP method to intercept a request for.
///
@@ -81,6 +121,7 @@ public HttpRequestInterceptionBuilder ForScheme(string scheme)
public HttpRequestInterceptionBuilder ForHost(string host)
{
_uriBuilder.Host = host;
+ _ignoreHost = false;
return this;
}
@@ -123,6 +164,17 @@ public HttpRequestInterceptionBuilder ForQuery(string query)
return this;
}
+ ///
+ /// If true URI paths will be ignored when testing request URIs.
+ ///
+ /// Whether to ignore paths or not; defaults to true.
+ /// The current .
+ public HttpRequestInterceptionBuilder IgnoringPath(bool ignorePath = true)
+ {
+ _ignorePath = ignorePath;
+ return this;
+ }
+
///
/// If true query strings will be ignored when testing request URIs.
///
@@ -144,6 +196,7 @@ public HttpRequestInterceptionBuilder IgnoringQuery(bool ignoreQuery = true)
public HttpRequestInterceptionBuilder ForUri(Uri uri)
{
_uriBuilder = new UriBuilder(uri);
+ _ignoreHost = false;
return this;
}
@@ -160,6 +213,7 @@ public HttpRequestInterceptionBuilder ForUri(Uri uri)
public HttpRequestInterceptionBuilder ForUri(UriBuilder uriBuilder)
{
_uriBuilder = uriBuilder ?? throw new ArgumentNullException(nameof(uriBuilder));
+ _ignoreHost = false;
return this;
}
@@ -544,6 +598,30 @@ public HttpRequestInterceptionBuilder WithInterceptionCallback(Func
+ /// Sets the priority for matching against HTTP requests.
+ ///
+ /// The priority of the HTTP interception.
+ ///
+ /// The current .
+ ///
+ ///
+ /// The priority is used to establish a hierarchy for matching of intercepted
+ /// HTTP requests, particularly when is used. This allows
+ /// registered requests to establish an order of precedence when an HTTP request
+ /// could match against multiple predicates, where the matching predicate with
+ /// the lowest value for dictates the HTTP response
+ /// that is used for the intercepted request.
+ ///
+ /// By default an interception has no priority, so the first arbitrary registration
+ /// that matches which does not have a priority will be used to provide the response.
+ ///
+ public HttpRequestInterceptionBuilder HavingPriority(int? priority)
+ {
+ _priority = priority;
+ return this;
+ }
+
internal HttpInterceptionResponse Build()
{
var response = new HttpInterceptionResponse()
@@ -551,12 +629,16 @@ internal HttpInterceptionResponse Build()
ContentFactory = _contentFactory ?? EmptyContentFactory,
ContentStream = _contentStream,
ContentMediaType = _mediaType,
+ IgnoreHost = _ignoreHost,
+ IgnorePath = _ignorePath,
IgnoreQuery = _ignoreQuery,
Method = _method,
OnIntercepted = _onIntercepted,
+ Priority = _priority,
ReasonPhrase = _reasonPhrase,
RequestUri = _uriBuilder.Uri,
StatusCode = _statusCode,
+ UserMatcher = _requestMatcher,
Version = _version,
};
diff --git a/src/HttpClientInterception/HttpRequestInterceptionBuilderExtensions.cs b/src/HttpClientInterception/HttpRequestInterceptionBuilderExtensions.cs
index 1ccdf2a9..2493c5da 100644
--- a/src/HttpClientInterception/HttpRequestInterceptionBuilderExtensions.cs
+++ b/src/HttpClientInterception/HttpRequestInterceptionBuilderExtensions.cs
@@ -212,5 +212,46 @@ public static HttpRequestInterceptionBuilder ForUrl(this HttpRequestInterception
return builder.ForUri(new Uri(uriString));
}
+
+ ///
+ /// A convenience method that can be used to signify the start of request method calls for fluent registrations.
+ ///
+ /// The to use.
+ ///
+ /// The value specified by .
+ ///
+ public static HttpRequestInterceptionBuilder Requests(this HttpRequestInterceptionBuilder builder) => builder;
+
+ ///
+ /// A convenience method that can be used to signify the start of response method calls for fluent registrations.
+ ///
+ /// The to use.
+ ///
+ /// The value specified by .
+ ///
+ public static HttpRequestInterceptionBuilder Responds(this HttpRequestInterceptionBuilder builder) => builder;
+
+ ///
+ /// Registers the builder with the specified instance.
+ ///
+ /// The to use to create the registration.
+ /// The to register the builder with.
+ ///
+ /// The current .
+ ///
+ ///
+ /// or is .
+ ///
+ public static HttpRequestInterceptionBuilder RegisterWith(this HttpRequestInterceptionBuilder builder, HttpClientInterceptorOptions options)
+ {
+ if (options == null)
+ {
+ throw new ArgumentNullException(nameof(options));
+ }
+
+ options.Register(builder);
+
+ return builder;
+ }
}
}
diff --git a/src/HttpClientInterception/InterceptingHttpMessageHandler.cs b/src/HttpClientInterception/InterceptingHttpMessageHandler.cs
index af7ba82b..31943996 100644
--- a/src/HttpClientInterception/InterceptingHttpMessageHandler.cs
+++ b/src/HttpClientInterception/InterceptingHttpMessageHandler.cs
@@ -56,6 +56,11 @@ protected override async Task SendAsync(HttpRequestMessage
HttpResponseMessage response = await _options.GetResponseAsync(request);
+ if (response == null && _options.OnMissingRegistration != null)
+ {
+ response = await _options.OnMissingRegistration(request);
+ }
+
if (response != null)
{
return response;
diff --git a/src/HttpClientInterception/Matching/DelegatingMatcher.cs b/src/HttpClientInterception/Matching/DelegatingMatcher.cs
new file mode 100644
index 00000000..245b2e80
--- /dev/null
+++ b/src/HttpClientInterception/Matching/DelegatingMatcher.cs
@@ -0,0 +1,32 @@
+// Copyright (c) Just Eat, 2017. All rights reserved.
+// Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information.
+
+using System;
+using System.Net.Http;
+
+namespace JustEat.HttpClientInterception.Matching
+{
+ ///
+ /// A class representing an implementation of that
+ /// delegates to a user-provided delegate. This class cannot be inherited.
+ ///
+ internal sealed class DelegatingMatcher : RequestMatcher
+ {
+ ///
+ /// The user-provided predicate to use to test for a match. This field is read-only.
+ ///
+ private readonly Predicate _predicate;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The user-provided delegate to use for matching.
+ internal DelegatingMatcher(Predicate predicate)
+ {
+ _predicate = predicate;
+ }
+
+ ///
+ public override bool IsMatch(HttpRequestMessage request) => _predicate(request);
+ }
+}
diff --git a/src/HttpClientInterception/Matching/RegistrationMatcher.cs b/src/HttpClientInterception/Matching/RegistrationMatcher.cs
new file mode 100644
index 00000000..fa13560b
--- /dev/null
+++ b/src/HttpClientInterception/Matching/RegistrationMatcher.cs
@@ -0,0 +1,86 @@
+// Copyright (c) Just Eat, 2017. All rights reserved.
+// Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information.
+
+using System;
+using System.Collections.Generic;
+using System.Net.Http;
+
+namespace JustEat.HttpClientInterception.Matching
+{
+ ///
+ /// A class representing an implementation of that matches against
+ /// an instance of . This class cannot be inherited.
+ ///
+ internal sealed class RegistrationMatcher : RequestMatcher
+ {
+ ///
+ /// The string comparer to use to match URIs. This field is read-only.
+ ///
+ private readonly IEqualityComparer _comparer;
+
+ ///
+ /// The to use to generate URIs for comparison. This field is read-only.
+ ///
+ private readonly HttpInterceptionResponse _registration;
+
+ ///
+ /// The pre-computed URI that requests are required to match to. This field is read-only.
+ ///
+ private readonly string _expected;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The to use for matching.
+ /// The string comparer to use to compare URIs.
+ internal RegistrationMatcher(HttpInterceptionResponse registration, IEqualityComparer comparer)
+ {
+ _comparer = comparer;
+ _registration = registration;
+ _expected = GetUriStringForMatch(registration);
+ }
+
+ ///
+ public override bool IsMatch(HttpRequestMessage request)
+ {
+ if (request.RequestUri == null || request.Method != _registration.Method)
+ {
+ return false;
+ }
+
+ string actual = GetUriStringForMatch(_registration, request.RequestUri);
+
+ return _comparer.Equals(_expected, actual);
+ }
+
+ ///
+ /// Returns a that can be used to compare a URI against another.
+ ///
+ /// The to use to build the URI.
+ /// The optional to use to build the URI if not using the one associated with .
+ ///
+ /// A containing the URI to use for string comparisons for URIs.
+ ///
+ private static string GetUriStringForMatch(HttpInterceptionResponse registration, Uri uri = null)
+ {
+ var builder = new UriBuilder(uri ?? registration.RequestUri);
+
+ if (registration.IgnoreHost)
+ {
+ builder.Host = "*";
+ }
+
+ if (registration.IgnorePath)
+ {
+ builder.Path = string.Empty;
+ }
+
+ if (registration.IgnoreQuery)
+ {
+ builder.Query = string.Empty;
+ }
+
+ return builder.ToString();
+ }
+ }
+}
diff --git a/src/HttpClientInterception/Matching/RequestMatcher.cs b/src/HttpClientInterception/Matching/RequestMatcher.cs
new file mode 100644
index 00000000..4933c034
--- /dev/null
+++ b/src/HttpClientInterception/Matching/RequestMatcher.cs
@@ -0,0 +1,22 @@
+// Copyright (c) Just Eat, 2017. All rights reserved.
+// Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information.
+
+using System.Net.Http;
+
+namespace JustEat.HttpClientInterception.Matching
+{
+ ///
+ /// Represents a class that tests for matches for HTTP requests for a registered HTTP interception.
+ ///
+ internal abstract class RequestMatcher
+ {
+ ///
+ /// Returns a value indicating whether the specified is considered a match.
+ ///
+ /// The HTTP request to consider a match against.
+ ///
+ /// if is considered a match; otherwise .
+ ///
+ public abstract bool IsMatch(HttpRequestMessage request);
+ }
+}
diff --git a/tests/HttpClientInterception.Benchmarks/JustEat.HttpClientInterception.Benchmarks.csproj b/tests/HttpClientInterception.Benchmarks/JustEat.HttpClientInterception.Benchmarks.csproj
index 2e7a98e8..e31a4c66 100644
--- a/tests/HttpClientInterception.Benchmarks/JustEat.HttpClientInterception.Benchmarks.csproj
+++ b/tests/HttpClientInterception.Benchmarks/JustEat.HttpClientInterception.Benchmarks.csproj
@@ -13,7 +13,7 @@
-
-
+
+
diff --git a/tests/HttpClientInterception.Tests/Examples.cs b/tests/HttpClientInterception.Tests/Examples.cs
index 99f4a654..a79f8aef 100644
--- a/tests/HttpClientInterception.Tests/Examples.cs
+++ b/tests/HttpClientInterception.Tests/Examples.cs
@@ -26,11 +26,8 @@ public static async Task Intercept_Http_Get_For_Json_Object()
{
// Arrange
var builder = new HttpRequestInterceptionBuilder()
- .ForGet()
- .ForHttps()
- .ForHost("public.je-apis.com")
- .ForPath("terms")
- .WithJsonContent(new { Id = 1, Link = "https://www.just-eat.co.uk/privacy-policy" });
+ .Requests().ForGet().ForHttps().ForHost("public.je-apis.com").ForPath("terms")
+ .Responds().WithJsonContent(new { Id = 1, Link = "https://www.just-eat.co.uk/privacy-policy" });
var options = new HttpClientInterceptorOptions()
.Register(builder);
@@ -520,5 +517,113 @@ public static async Task Use_The_Same_Builder_For_Multiple_Registrations_On_The_
dotnetOrg.Login.ShouldBe("dotnet");
dotnetOrg.Url.ShouldBe("https://api.github.com/orgs/dotnet");
}
+
+ [Fact]
+ public static async Task Match_Any_Host_Name()
+ {
+ // Arrange
+ string expected = @"{""id"":12}";
+ string actual;
+
+ var builder = new HttpRequestInterceptionBuilder()
+ .ForHttp()
+ .ForAnyHost()
+ .ForPath("orders")
+ .ForQuery("id=12")
+ .WithStatus(HttpStatusCode.OK)
+ .WithContent(expected);
+
+ var options = new HttpClientInterceptorOptions().Register(builder);
+
+ using (var client = options.CreateHttpClient())
+ {
+ // Act
+ actual = await client.GetStringAsync("http://myhost.net/orders?id=12");
+ }
+
+ // Assert
+ actual.ShouldBe(expected);
+
+ using (var client = options.CreateHttpClient())
+ {
+ // Act
+ actual = await client.GetStringAsync("http://myotherhost.net/orders?id=12");
+ }
+
+ // Assert
+ actual.ShouldBe(expected);
+ }
+
+ [Fact]
+ public static async Task Use_Default_Response_For_Unmatched_Requests()
+ {
+ // Arrange
+ var options = new HttpClientInterceptorOptions()
+ {
+ OnMissingRegistration = (request) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound))
+ };
+
+ using (var client = options.CreateHttpClient())
+ {
+ // Act
+ using (var response = await client.GetAsync("https://google.com/"))
+ {
+ // Assert
+ response.StatusCode.ShouldBe(HttpStatusCode.NotFound);
+ }
+ }
+ }
+
+ [Fact]
+ public static async Task Use_Custom_Request_Matching()
+ {
+ // Arrange
+ var builder = new HttpRequestInterceptionBuilder()
+ .Requests().For((request) => request.RequestUri.Host == "google.com")
+ .Responds().WithContent(@"Google Search");
+
+ var options = new HttpClientInterceptorOptions().Register(builder);
+
+ using (var client = options.CreateHttpClient())
+ {
+ // Act and Assert
+ (await client.GetStringAsync("https://google.com/")).ShouldContain("Google Search");
+ (await client.GetStringAsync("https://google.com/search")).ShouldContain("Google Search");
+ (await client.GetStringAsync("https://google.com/search?q=foo")).ShouldContain("Google Search");
+ }
+ }
+
+ [Fact]
+ public static async Task Use_Custom_Request_Matching_With_Priorities()
+ {
+ // Arrange
+ var options = new HttpClientInterceptorOptions()
+ {
+ ThrowOnMissingRegistration = true,
+ };
+
+ var builder = new HttpRequestInterceptionBuilder()
+ .Requests().For((request) => request.RequestUri.Host == "google.com").HavingPriority(1)
+ .Responds().WithContent(@"First")
+ .RegisterWith(options)
+ .Requests().For((request) => request.RequestUri.Host.Contains("google")).HavingPriority(2)
+ .Responds().WithContent(@"Second")
+ .RegisterWith(options)
+ .Requests().For((request) => request.RequestUri.PathAndQuery.Contains("html")).HavingPriority(3)
+ .Responds().WithContent(@"Third")
+ .RegisterWith(options)
+ .Requests().For((request) => true).HavingPriority(null)
+ .Responds().WithContent(@"Fourth")
+ .RegisterWith(options);
+
+ using (var client = options.CreateHttpClient())
+ {
+ // Act and Assert
+ (await client.GetStringAsync("https://google.com/")).ShouldBe("First");
+ (await client.GetStringAsync("https://google.co.uk")).ShouldContain("Second");
+ (await client.GetStringAsync("https://example.org/index.html")).ShouldContain("Third");
+ (await client.GetStringAsync("https://www.just-eat.co.uk/")).ShouldContain("Fourth");
+ }
+ }
}
}
diff --git a/tests/HttpClientInterception.Tests/HttpAssert.cs b/tests/HttpClientInterception.Tests/HttpAssert.cs
index d8c74ab8..1df7b13b 100644
--- a/tests/HttpClientInterception.Tests/HttpAssert.cs
+++ b/tests/HttpClientInterception.Tests/HttpAssert.cs
@@ -83,6 +83,11 @@ internal static async Task SendAsync(
response.Content.Headers.ContentType.MediaType.ShouldBe(mediaType);
}
+ if (response.Content == null)
+ {
+ return null;
+ }
+
return await response.Content.ReadAsStringAsync();
}
}
diff --git a/tests/HttpClientInterception.Tests/HttpClientInterceptorOptionsTests.cs b/tests/HttpClientInterception.Tests/HttpClientInterceptorOptionsTests.cs
index 534e86bd..a47eeecd 100644
--- a/tests/HttpClientInterception.Tests/HttpClientInterceptorOptionsTests.cs
+++ b/tests/HttpClientInterception.Tests/HttpClientInterceptorOptionsTests.cs
@@ -62,6 +62,22 @@ public static async Task GetResponseAsync_Returns_Null_If_Request_Uri_Does_Not_M
actual.ShouldBeNull();
}
+ [Fact]
+ public static async Task GetResponseAsync_Returns_Null_If_Http_Method_Does_Not_Match()
+ {
+ // Arrange
+ var options = new HttpClientInterceptorOptions()
+ .Register(HttpMethod.Get, new Uri("https://google.com/"), contentFactory: EmptyContent);
+
+ var request = new HttpRequestMessage(HttpMethod.Post, "https://google.com/");
+
+ // Act
+ HttpResponseMessage actual = await options.GetResponseAsync(request);
+
+ // Assert
+ actual.ShouldBeNull();
+ }
+
[Fact]
public static async Task GetResponseAsync_Returns_Null_If_Request_Uri_Does_Not_Match_Blank_Request()
{
@@ -379,6 +395,21 @@ public static async Task HttpClient_Registrations_Can_Be_Removed()
await Assert.ThrowsAsync(() => HttpAssert.GetAsync(options, "https://google.com/"));
await HttpAssert.GetAsync(options, "https://google.co.uk/");
await HttpAssert.GetAsync(options, "https://google.co.uk/");
+
+ // Arrange
+ var builder = new HttpRequestInterceptionBuilder()
+ .ForHttps()
+ .ForGet()
+ .ForHost("bing.com");
+
+ options.ThrowOnMissingRegistration = true;
+ options.Register(builder);
+
+ await HttpAssert.GetAsync(options, "https://bing.com/");
+
+ options.Deregister(builder);
+
+ await Assert.ThrowsAsync(() => HttpAssert.GetAsync(options, "https://bing.com/"));
}
[Fact]
@@ -488,6 +519,18 @@ public static void Deregister_Throws_If_Uri_Is_Null()
Assert.Throws("uri", () => options.Deregister(method, uri));
}
+ [Fact]
+ public static void Deregister_Throws_If_Builder_Is_Null()
+ {
+ // Arrange
+ var options = new HttpClientInterceptorOptions();
+
+ HttpRequestInterceptionBuilder builder = null;
+
+ // Act and Assert
+ Assert.Throws("builder", () => options.Deregister(builder));
+ }
+
[Fact]
public static void Register_Throws_If_Method_Is_Null()
{
diff --git a/tests/HttpClientInterception.Tests/HttpRequestInterceptionBuilderTests.cs b/tests/HttpClientInterception.Tests/HttpRequestInterceptionBuilderTests.cs
index 92f2e3ef..8a789b53 100644
--- a/tests/HttpClientInterception.Tests/HttpRequestInterceptionBuilderTests.cs
+++ b/tests/HttpClientInterception.Tests/HttpRequestInterceptionBuilderTests.cs
@@ -557,6 +557,42 @@ public static async Task Register_Builds_Uri_From_Components_From_Builder_With_H
(await HttpAssert.GetAsync(options, "http://something.com")).ShouldBeEmpty();
}
+ [Fact]
+ public static async Task Register_Builds_Ignore_Path_Key()
+ {
+ // Arrange
+ var options = new HttpClientInterceptorOptions();
+
+ HttpRequestInterceptionBuilder builder = new HttpRequestInterceptionBuilder()
+ .ForHost("something.com")
+ .IgnoringPath();
+
+ // Act
+ options.Register(builder);
+
+ // Assert
+ (await HttpAssert.GetAsync(options, "http://something.com/path/1234")).ShouldBeEmpty();
+ }
+
+ [Fact]
+ public static async Task Register_Builds_Without_Ignore_Path_Key()
+ {
+ // Arrange
+ var options = new HttpClientInterceptorOptions();
+
+ HttpRequestInterceptionBuilder builder = new HttpRequestInterceptionBuilder()
+ .ForHost("something.com")
+ .IgnoringPath()
+ .IgnoringPath(false);
+
+ // Act
+ options.Register(builder);
+
+ // Assert
+ await Assert.ThrowsAsync(() =>
+ HttpAssert.GetAsync(options, "http://something.com/path/1234"));
+ }
+
[Fact]
public static async Task Register_Builds_Ignore_Query_Key()
{
@@ -933,6 +969,77 @@ public static async Task Register_For_Callback_Clears_Delegate_For_Predicate_If_
wasDelegateInvoked.ShouldBeFalse();
}
+ [Fact]
+ public static async Task Builder_For_Any_Host_Registers_Interception()
+ {
+ // Arrange
+ string expected = "foo>";
+
+ var builder = new HttpRequestInterceptionBuilder()
+ .ForAnyHost()
+ .WithContent(expected);
+
+ var options = new HttpClientInterceptorOptions().Register(builder);
+
+ // Act
+ string actual1 = await HttpAssert.GetAsync(options, "http://google.com/");
+ string actual2 = await HttpAssert.GetAsync(options, "http://bing.com/");
+
+ // Assert
+ actual1.ShouldBe(expected);
+ actual2.ShouldBe(actual1);
+ }
+
+ [Fact]
+ public static async Task Builder_For_Any_Host_Path_And_Query_Registers_Interception()
+ {
+ // Arrange
+ string expected = "foo>";
+
+ var builder = new HttpRequestInterceptionBuilder()
+ .ForAnyHost()
+ .IgnoringPath()
+ .IgnoringQuery()
+ .WithContent(expected);
+
+ var options = new HttpClientInterceptorOptions().Register(builder);
+
+ // Act
+ string actual1 = await HttpAssert.GetAsync(options, "http://google.com/blah/?foo=bar");
+ string actual2 = await HttpAssert.GetAsync(options, "http://bing.com/qux/?foo=baz");
+
+ // Assert
+ actual1.ShouldBe(expected);
+ actual2.ShouldBe(actual1);
+ }
+
+ [Fact]
+ public static async Task Builder_Returns_Fallback_For_Missing_Registration()
+ {
+ // Arrange
+ var builder = new HttpRequestInterceptionBuilder()
+ .ForHost("google.com")
+ .WithStatus(HttpStatusCode.BadRequest);
+
+ var options = new HttpClientInterceptorOptions().Register(builder);
+
+ options.OnMissingRegistration = (request) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.BadGateway));
+
+ // Act and Assert
+ await HttpAssert.GetAsync(options, "http://google.com/", HttpStatusCode.BadRequest);
+ await HttpAssert.GetAsync(options, "http://bing.com/", HttpStatusCode.BadGateway);
+ }
+
+ [Fact]
+ public static void RegisterWith_Validates_Parameters()
+ {
+ // Arrange
+ var builder = new HttpRequestInterceptionBuilder();
+
+ // Act and Assert
+ Assert.Throws("options", () => builder.RegisterWith(null));
+ }
+
private sealed class CustomObject
{
internal enum Color
diff --git a/tests/HttpClientInterception.Tests/InterceptingHttpMessageHandlerTests.cs b/tests/HttpClientInterception.Tests/InterceptingHttpMessageHandlerTests.cs
index 635c1ed9..d7412f3e 100644
--- a/tests/HttpClientInterception.Tests/InterceptingHttpMessageHandlerTests.cs
+++ b/tests/HttpClientInterception.Tests/InterceptingHttpMessageHandlerTests.cs
@@ -6,6 +6,7 @@
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
+using System.Net;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
@@ -81,9 +82,11 @@ public static async Task SendAsync_Calls_Inner_Handler_If_Registration_Missing()
.Register(HttpMethod.Options, new Uri("http://google.com/foo"), () => Array.Empty())
.Register(HttpMethod.Options, new Uri("https://google.com/FOO"), () => Array.Empty());
+ options.OnMissingRegistration = (request) => Task.FromResult(null);
+
var mock = new Mock();
- using (var expected = new HttpResponseMessage(System.Net.HttpStatusCode.OK))
+ using (var expected = new HttpResponseMessage(HttpStatusCode.OK))
{
using (var request = new HttpRequestMessage(HttpMethod.Options, "https://google.com/foo"))
{
@@ -147,5 +150,55 @@ Task GetAsync(int id)
requestIds.Count.ShouldBe(expected);
requestIds.ShouldBeUnique();
}
+
+ [Fact]
+ public static async Task SendAsync_Calls_OnMissingRegistration_With_RequestMessage()
+ {
+ // Arrange
+ var header = "x-request";
+ var requestUrl = "https://google.com/foo";
+
+ var options = new HttpClientInterceptorOptions();
+
+ int expected = 7;
+ int actual = 0;
+
+ var requestIds = new ConcurrentBag();
+
+ options.OnMissingRegistration = (p) =>
+ {
+ Interlocked.Increment(ref actual);
+ requestIds.Add(p.Headers.GetValues(header).FirstOrDefault());
+
+ var response = new HttpResponseMessage(HttpStatusCode.Accepted)
+ {
+ Content = new StringContent(string.Empty)
+ };
+
+ return Task.FromResult(response);
+ };
+
+ Task GetAsync(int id)
+ {
+ var headers = new Dictionary()
+ {
+ [header] = id.ToString(CultureInfo.InvariantCulture)
+ };
+
+ return HttpAssert.GetAsync(options, requestUrl, HttpStatusCode.Accepted, responseHeaders: headers);
+ }
+
+ // Act
+ var tasks = Enumerable.Range(0, expected)
+ .Select(GetAsync)
+ .ToArray();
+
+ await Task.WhenAll(tasks);
+
+ // Assert
+ actual.ShouldBe(expected);
+ requestIds.Count.ShouldBe(expected);
+ requestIds.ShouldBeUnique();
+ }
}
}
diff --git a/tests/HttpClientInterception.Tests/JustEat.HttpClientInterception.Tests.csproj b/tests/HttpClientInterception.Tests/JustEat.HttpClientInterception.Tests.csproj
index cc2f7509..53e5c33e 100644
--- a/tests/HttpClientInterception.Tests/JustEat.HttpClientInterception.Tests.csproj
+++ b/tests/HttpClientInterception.Tests/JustEat.HttpClientInterception.Tests.csproj
@@ -13,9 +13,9 @@
-
+
-
+