From e91d2da970b27b5c3316a06f3903329c89240521 Mon Sep 17 00:00:00 2001 From: martincostello Date: Sat, 3 Mar 2018 08:33:19 +0000 Subject: [PATCH 01/11] Add support for matching any host Add support for configuring a response that matches to any host name. --- .../HttpClientInterceptorOptions.cs | 111 +++++++++++++----- .../HttpInterceptionResponse.cs | 2 + .../HttpRequestInterceptionBuilder.cs | 19 +++ .../HttpClientInterception.Tests/Examples.cs | 36 ++++++ .../HttpRequestInterceptionBuilderTests.cs | 43 +++++++ 5 files changed, 180 insertions(+), 31 deletions(-) diff --git a/src/HttpClientInterception/HttpClientInterceptorOptions.cs b/src/HttpClientInterception/HttpClientInterceptorOptions.cs index cc050a1e..885a051d 100644 --- a/src/HttpClientInterception/HttpClientInterceptorOptions.cs +++ b/src/HttpClientInterception/HttpClientInterceptorOptions.cs @@ -272,7 +272,7 @@ public HttpClientInterceptorOptions Register(HttpRequestInterceptionBuilder buil HttpInterceptionResponse interceptor = builder.Build(); - string key = BuildKey(interceptor.Method, interceptor.RequestUri, interceptor.IgnoreQuery); + string key = BuildKey(interceptor); _mappings[key] = interceptor; return this; @@ -296,62 +296,55 @@ 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)) + if (!TryGetResponse(request, out HttpInterceptionResponse response)) { - string keyWithoutQueryString = BuildKey(request.Method, request.RequestUri, ignoreQueryString: true); - - if (!_mappings.TryGetValue(keyWithoutQueryString, out options)) - { - return null; - } + return null; } - if (options.OnIntercepted != null && !await options.OnIntercepted(request)) + if (response.OnIntercepted != null && !await response.OnIntercepted(request)) { return null; } - var result = new HttpResponseMessage(options.StatusCode); + var result = new HttpResponseMessage(response.StatusCode); try { result.RequestMessage = request; - if (options.ReasonPhrase != null) + if (response.ReasonPhrase != null) { - result.ReasonPhrase = options.ReasonPhrase; + result.ReasonPhrase = response.ReasonPhrase; } - if (options.Version != null) + if (response.Version != null) { - result.Version = options.Version; + result.Version = response.Version; } - if (options.ContentStream != null) + if (response.ContentStream != null) { - result.Content = new StreamContent(await options.ContentStream() ?? Stream.Null); + result.Content = new StreamContent(await response.ContentStream() ?? Stream.Null); } else { - byte[] content = await options.ContentFactory() ?? Array.Empty(); + byte[] content = await response.ContentFactory() ?? Array.Empty(); result.Content = new ByteArrayContent(content); } - if (options.ContentHeaders != null) + if (response.ContentHeaders != null) { - foreach (var pair in options.ContentHeaders) + foreach (var pair in response.ContentHeaders) { result.Content.Headers.Add(pair.Key, pair.Value); } } - result.Content.Headers.ContentType = new MediaTypeHeaderValue(options.ContentMediaType); + result.Content.Headers.ContentType = new MediaTypeHeaderValue(response.ContentMediaType); - if (options.ResponseHeaders != null) + if (response.ResponseHeaders != null) { - foreach (var pair in options.ResponseHeaders) + foreach (var pair in response.ResponseHeaders) { result.Headers.Add(pair.Key, pair.Value); } @@ -387,27 +380,83 @@ public virtual HttpClient CreateHttpClient(HttpMessageHandler innerHandler = nul return new HttpClient(handler, true); } + /// + /// Builds the mapping key to use for the specified intercepted HTTP request. + /// + /// The configured HTTP interceptor. + /// + /// A to use as the key for the interceptor registration. + /// + private static string BuildKey(HttpInterceptionResponse interceptor) + { + return BuildKey( + interceptor.Method, + interceptor.RequestUri, + interceptor.IgnoreQuery, + interceptor.IgnoreHost); + } + /// /// Builds the mapping key to use for the specified 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. + /// If true, creates a key without any query string but with an extra string to disambiguate. + /// If true, creates a key that will match for any hostname. /// /// 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( + HttpMethod method, + Uri uri, + bool ignoreQueryString = false, + bool ignoreHostName = false) { - if (ignoreQueryString) + if (uri == null) { - var uriWithoutQueryString = uri == null ? null : new UriBuilder(uri) { Query = string.Empty }.Uri; + return string.Empty; + } - return $"{method.Method}:IGNOREQUERY:{uriWithoutQueryString}"; + var builderForKey = new UriBuilder(uri); + string keyPrefix = string.Empty; + + if (ignoreHostName) + { + builderForKey.Host = "*"; + keyPrefix = "IGNOREHOST;"; } - else + + if (ignoreQueryString) { - return $"{method.Method}:{uri}"; + builderForKey.Query = string.Empty; + keyPrefix += "IGNOREQUERY;"; } + + return $"{keyPrefix};{method.Method}:{builderForKey}"; + } + + private bool TryGetResponse(HttpRequestMessage request, out HttpInterceptionResponse response) + { + var candidateKeyGenerators = new Func[] + { + () => BuildKey(request.Method, request.RequestUri), + () => BuildKey(request.Method, request.RequestUri, ignoreQueryString: true), + () => BuildKey(request.Method, request.RequestUri, ignoreHostName: true), + () => BuildKey(request.Method, request.RequestUri, ignoreQueryString: true, ignoreHostName: true), + }; + + foreach (var keyGenerator in candidateKeyGenerators) + { + string key = keyGenerator(); + + if (_mappings.TryGetValue(key, out response)) + { + return true; + } + } + + response = null; + return false; } private sealed class OptionsScope : IDisposable diff --git a/src/HttpClientInterception/HttpInterceptionResponse.cs b/src/HttpClientInterception/HttpInterceptionResponse.cs index db234638..66ba7a10 100644 --- a/src/HttpClientInterception/HttpInterceptionResponse.cs +++ b/src/HttpClientInterception/HttpInterceptionResponse.cs @@ -28,6 +28,8 @@ internal sealed class HttpInterceptionResponse internal IEnumerable>> ContentHeaders { get; set; } + internal bool IgnoreHost { 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..5cfa30cd 100644 --- a/src/HttpClientInterception/HttpRequestInterceptionBuilder.cs +++ b/src/HttpClientInterception/HttpRequestInterceptionBuilder.cs @@ -40,8 +40,23 @@ public class HttpRequestInterceptionBuilder private UriBuilder _uriBuilder = new UriBuilder(); private Version _version; + + private bool _ignoreHost; + private bool _ignoreQuery; + /// + /// 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 +96,7 @@ public HttpRequestInterceptionBuilder ForScheme(string scheme) public HttpRequestInterceptionBuilder ForHost(string host) { _uriBuilder.Host = host; + _ignoreHost = false; return this; } @@ -144,6 +160,7 @@ public HttpRequestInterceptionBuilder IgnoringQuery(bool ignoreQuery = true) public HttpRequestInterceptionBuilder ForUri(Uri uri) { _uriBuilder = new UriBuilder(uri); + _ignoreHost = false; return this; } @@ -160,6 +177,7 @@ public HttpRequestInterceptionBuilder ForUri(Uri uri) public HttpRequestInterceptionBuilder ForUri(UriBuilder uriBuilder) { _uriBuilder = uriBuilder ?? throw new ArgumentNullException(nameof(uriBuilder)); + _ignoreHost = false; return this; } @@ -551,6 +569,7 @@ internal HttpInterceptionResponse Build() ContentFactory = _contentFactory ?? EmptyContentFactory, ContentStream = _contentStream, ContentMediaType = _mediaType, + IgnoreHost = _ignoreHost, IgnoreQuery = _ignoreQuery, Method = _method, OnIntercepted = _onIntercepted, diff --git a/tests/HttpClientInterception.Tests/Examples.cs b/tests/HttpClientInterception.Tests/Examples.cs index 99f4a654..964e89c9 100644 --- a/tests/HttpClientInterception.Tests/Examples.cs +++ b/tests/HttpClientInterception.Tests/Examples.cs @@ -520,5 +520,41 @@ 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); + } } } diff --git a/tests/HttpClientInterception.Tests/HttpRequestInterceptionBuilderTests.cs b/tests/HttpClientInterception.Tests/HttpRequestInterceptionBuilderTests.cs index 92f2e3ef..e18659f6 100644 --- a/tests/HttpClientInterception.Tests/HttpRequestInterceptionBuilderTests.cs +++ b/tests/HttpClientInterception.Tests/HttpRequestInterceptionBuilderTests.cs @@ -933,6 +933,49 @@ 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_And_Query_Registers_Interception() + { + // Arrange + string expected = "foo>"; + + var builder = new HttpRequestInterceptionBuilder() + .ForAnyHost() + .IgnoringQuery() + .WithContent(expected); + + var options = new HttpClientInterceptorOptions().Register(builder); + + // Act + string actual1 = await HttpAssert.GetAsync(options, "http://google.com/?foo=bar"); + string actual2 = await HttpAssert.GetAsync(options, "http://bing.com/?foo=baz"); + + // Assert + actual1.ShouldBe(expected); + actual2.ShouldBe(actual1); + } + private sealed class CustomObject { internal enum Color From d09ccba0f8c6320318741308774a9063189f5278 Mon Sep 17 00:00:00 2001 From: martincostello Date: Sat, 3 Mar 2018 08:55:37 +0000 Subject: [PATCH 02/11] Add support to respond for any unmatched request Add a callback delegate to OnMissingRegistration that allows an arbitrary HTTP response to be used for any registration that does not match a more specific registration. --- .../HttpClientInterceptorOptions.cs | 6 ++ .../InterceptingHttpMessageHandler.cs | 5 ++ .../HttpClientInterception.Tests/Examples.cs | 20 +++++++ .../HttpAssert.cs | 5 ++ .../HttpRequestInterceptionBuilderTests.cs | 17 ++++++ .../InterceptingHttpMessageHandlerTests.cs | 55 ++++++++++++++++++- 6 files changed, 107 insertions(+), 1 deletion(-) diff --git a/src/HttpClientInterception/HttpClientInterceptorOptions.cs b/src/HttpClientInterception/HttpClientInterceptorOptions.cs index 885a051d..f3fc3b7b 100644 --- a/src/HttpClientInterception/HttpClientInterceptorOptions.cs +++ b/src/HttpClientInterception/HttpClientInterceptorOptions.cs @@ -54,6 +54,12 @@ public HttpClientInterceptorOptions(bool caseSensitive) _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. /// 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/tests/HttpClientInterception.Tests/Examples.cs b/tests/HttpClientInterception.Tests/Examples.cs index 964e89c9..f6583e60 100644 --- a/tests/HttpClientInterception.Tests/Examples.cs +++ b/tests/HttpClientInterception.Tests/Examples.cs @@ -556,5 +556,25 @@ public static async Task Match_Any_Host_Name() // 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); + } + } + } } } 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/HttpRequestInterceptionBuilderTests.cs b/tests/HttpClientInterception.Tests/HttpRequestInterceptionBuilderTests.cs index e18659f6..a32698e1 100644 --- a/tests/HttpClientInterception.Tests/HttpRequestInterceptionBuilderTests.cs +++ b/tests/HttpClientInterception.Tests/HttpRequestInterceptionBuilderTests.cs @@ -976,6 +976,23 @@ public static async Task Builder_For_Any_Host_And_Query_Registers_Interception() 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); + } + 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(); + } } } From ea01b7f62c6fb406ba02f3073efa9937996fa521 Mon Sep 17 00:00:00 2001 From: martincostello Date: Sat, 3 Mar 2018 17:18:43 +0000 Subject: [PATCH 03/11] Support arbitrary request matching Support arbitrary matching of HTTP requests using a caller-specified delegate. Move existing matching logic to internal abstraction, rather than by computing keys. Now keys are only used when adding registrations so they can be un-registered. Add support for de-registering using builders (provided the configured state still matches). Fix typo in XML documentation. --- .../HttpClientInterceptorOptions.cs | 128 ++++++++++-------- .../HttpInterceptionResponse.cs | 4 + .../HttpRequestInterceptionBuilder.cs | 19 +++ .../Matching/DelegatingMatcher.cs | 32 +++++ .../Matching/RegistrationMatcher.cs | 81 +++++++++++ .../Matching/RequestMatcher.cs | 22 +++ .../HttpClientInterception.Tests/Examples.cs | 20 +++ .../HttpClientInterceptorOptionsTests.cs | 43 ++++++ 8 files changed, 294 insertions(+), 55 deletions(-) create mode 100644 src/HttpClientInterception/Matching/DelegatingMatcher.cs create mode 100644 src/HttpClientInterception/Matching/RegistrationMatcher.cs create mode 100644 src/HttpClientInterception/Matching/RequestMatcher.cs diff --git a/src/HttpClientInterception/HttpClientInterceptorOptions.cs b/src/HttpClientInterception/HttpClientInterceptorOptions.cs index f3fc3b7b..b7e92198 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,7 +50,7 @@ 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; @@ -105,6 +108,7 @@ public HttpClientInterceptorOptions Clone() ThrowOnMissingRegistration = ThrowOnMissingRegistration }; + clone._comparer = _comparer; clone._mappings = new ConcurrentDictionary(_mappings, _comparer); return clone; @@ -133,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; @@ -193,8 +233,7 @@ public HttpClientInterceptorOptions Register( StatusCode = statusCode }; - string key = BuildKey(method, uri); - _mappings[key] = interceptor; + ConfigureMatcherAndRegister(interceptor); return this; } @@ -253,8 +292,7 @@ public HttpClientInterceptorOptions Register( StatusCode = statusCode }; - string key = BuildKey(method, uri); - _mappings[key] = interceptor; + ConfigureMatcherAndRegister(interceptor); return this; } @@ -278,8 +316,7 @@ public HttpClientInterceptorOptions Register(HttpRequestInterceptionBuilder buil HttpInterceptionResponse interceptor = builder.Build(); - string key = BuildKey(interceptor); - _mappings[key] = interceptor; + ConfigureMatcherAndRegister(interceptor); return this; } @@ -395,74 +432,55 @@ public virtual HttpClient CreateHttpClient(HttpMessageHandler innerHandler = nul /// private static string BuildKey(HttpInterceptionResponse interceptor) { - return BuildKey( - interceptor.Method, - interceptor.RequestUri, - interceptor.IgnoreQuery, - interceptor.IgnoreHost); - } - - /// - /// Builds the mapping key to use for the specified HTTP request. - /// - /// The HTTP method. - /// The HTTP request URI. - /// If true, creates a key without any query string but with an extra string to disambiguate. - /// If true, creates a key that will match for any hostname. - /// - /// A to use as the key for the interceptor registration. - /// - private static string BuildKey( - HttpMethod method, - Uri uri, - bool ignoreQueryString = false, - bool ignoreHostName = false) - { - if (uri == null) + if (interceptor.UserMatcher != null) { - return string.Empty; + return $"CUSTOM:{interceptor.UserMatcher.GetHashCode().ToString(CultureInfo.InvariantCulture)}"; } - var builderForKey = new UriBuilder(uri); + var builderForKey = new UriBuilder(interceptor.RequestUri); string keyPrefix = string.Empty; - if (ignoreHostName) + if (interceptor.IgnoreHost) { builderForKey.Host = "*"; keyPrefix = "IGNOREHOST;"; } - if (ignoreQueryString) + if (interceptor.IgnoreQuery) { builderForKey.Query = string.Empty; keyPrefix += "IGNOREQUERY;"; } - return $"{keyPrefix};{method.Method}:{builderForKey}"; + return $"{keyPrefix};{interceptor.Method.Method}:{builderForKey}"; } private bool TryGetResponse(HttpRequestMessage request, out HttpInterceptionResponse response) { - var candidateKeyGenerators = new Func[] - { - () => BuildKey(request.Method, request.RequestUri), - () => BuildKey(request.Method, request.RequestUri, ignoreQueryString: true), - () => BuildKey(request.Method, request.RequestUri, ignoreHostName: true), - () => BuildKey(request.Method, request.RequestUri, ignoreQueryString: true, ignoreHostName: true), - }; + response = _mappings.Values + .Where((p) => p.InternalMatcher.IsMatch(request)) + .FirstOrDefault(); - foreach (var keyGenerator in candidateKeyGenerators) - { - string key = keyGenerator(); + return response != null; + } - if (_mappings.TryGetValue(key, out response)) - { - return true; - } + private void ConfigureMatcherAndRegister(HttpInterceptionResponse registration) + { + RequestMatcher matcher; + + if (registration.UserMatcher != null) + { + matcher = new DelegatingMatcher(registration.UserMatcher); } + else + { + matcher = new RegistrationMatcher(registration, _comparer); + } + + registration.InternalMatcher = matcher; - response = null; - return false; + string key = BuildKey(registration); + _mappings[key] = registration; } private sealed class OptionsScope : IDisposable diff --git a/src/HttpClientInterception/HttpInterceptionResponse.cs b/src/HttpClientInterception/HttpInterceptionResponse.cs index 66ba7a10..8272083b 100644 --- a/src/HttpClientInterception/HttpInterceptionResponse.cs +++ b/src/HttpClientInterception/HttpInterceptionResponse.cs @@ -12,6 +12,10 @@ namespace JustEat.HttpClientInterception { internal sealed class HttpInterceptionResponse { + internal Predicate UserMatcher { get; set; } + + internal Matching.RequestMatcher InternalMatcher { get; set; } + internal HttpMethod Method { get; set; } internal string ReasonPhrase { get; set; } diff --git a/src/HttpClientInterception/HttpRequestInterceptionBuilder.cs b/src/HttpClientInterception/HttpRequestInterceptionBuilder.cs index 5cfa30cd..cd3f31aa 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; @@ -45,6 +47,22 @@ public class HttpRequestInterceptionBuilder private bool _ignoreQuery; + /// + /// Configures the builder to match any request that meets the criteria defined by the specified predicate. + /// + /// A delgate to a method which returns if the request is considered a match. + /// + /// The current . + /// + /// + /// Pass a value of to remove a previously-registered custom request matching predicate. + /// + public HttpRequestInterceptionBuilder ForRequest(Predicate predicate) + { + _requestMatcher = predicate; + return this; + } + /// /// Configures the builder to match any host name. /// @@ -576,6 +594,7 @@ internal HttpInterceptionResponse Build() ReasonPhrase = _reasonPhrase, RequestUri = _uriBuilder.Uri, StatusCode = _statusCode, + UserMatcher = _requestMatcher, Version = _version, }; 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..1bb4df64 --- /dev/null +++ b/src/HttpClientInterception/Matching/RegistrationMatcher.cs @@ -0,0 +1,81 @@ +// 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.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.Tests/Examples.cs b/tests/HttpClientInterception.Tests/Examples.cs index f6583e60..e93c2b71 100644 --- a/tests/HttpClientInterception.Tests/Examples.cs +++ b/tests/HttpClientInterception.Tests/Examples.cs @@ -576,5 +576,25 @@ public static async Task Use_Default_Response_For_Unmatched_Requests() } } } + + [Fact] + public static async Task Use_Custom_Request_Matching() + { + // Arrange + var builder = new HttpRequestInterceptionBuilder() + .ForRequest((request) => request.RequestUri.Host == "google.com") + .WithMediaType("text/html") + .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"); + } + } } } 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() { From 61b8e0a28754aced034873fcac94ae594fdd0c25 Mon Sep 17 00:00:00 2001 From: martincostello Date: Sat, 3 Mar 2018 17:29:20 +0000 Subject: [PATCH 04/11] Refactor for a more fluent style Rename ForRequest() to For(). Add new Requests() and Responds() extension methods to allow for a more fluent style of describing setups for human readability when using HttpRequestInterceptionBuilder. Fix some typos in XML documentation for For(). --- .../HttpRequestInterceptionBuilder.cs | 7 +++++-- ...HttpRequestInterceptionBuilderExtensions.cs | 18 ++++++++++++++++++ tests/HttpClientInterception.Tests/Examples.cs | 12 ++++-------- 3 files changed, 27 insertions(+), 10 deletions(-) diff --git a/src/HttpClientInterception/HttpRequestInterceptionBuilder.cs b/src/HttpClientInterception/HttpRequestInterceptionBuilder.cs index cd3f31aa..58df89a4 100644 --- a/src/HttpClientInterception/HttpRequestInterceptionBuilder.cs +++ b/src/HttpClientInterception/HttpRequestInterceptionBuilder.cs @@ -50,14 +50,17 @@ public class HttpRequestInterceptionBuilder /// /// Configures the builder to match any request that meets the criteria defined by the specified predicate. /// - /// A delgate to a method which returns if the request is considered a match. + /// + /// 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 ForRequest(Predicate predicate) + public HttpRequestInterceptionBuilder For(Predicate predicate) { _requestMatcher = predicate; return this; diff --git a/src/HttpClientInterception/HttpRequestInterceptionBuilderExtensions.cs b/src/HttpClientInterception/HttpRequestInterceptionBuilderExtensions.cs index 1ccdf2a9..ad22ec22 100644 --- a/src/HttpClientInterception/HttpRequestInterceptionBuilderExtensions.cs +++ b/src/HttpClientInterception/HttpRequestInterceptionBuilderExtensions.cs @@ -212,5 +212,23 @@ 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; } } diff --git a/tests/HttpClientInterception.Tests/Examples.cs b/tests/HttpClientInterception.Tests/Examples.cs index e93c2b71..343fcbd8 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); @@ -582,9 +579,8 @@ public static async Task Use_Custom_Request_Matching() { // Arrange var builder = new HttpRequestInterceptionBuilder() - .ForRequest((request) => request.RequestUri.Host == "google.com") - .WithMediaType("text/html") - .WithContent(@"Google Search"); + .Requests().For((request) => request.RequestUri.Host == "google.com") + .Responds().WithContent(@"Google Search"); var options = new HttpClientInterceptorOptions().Register(builder); From 4e89f4f6a82fbf9e86e25c0427b709c84df57a4b Mon Sep 17 00:00:00 2001 From: martincostello Date: Sat, 3 Mar 2018 17:38:27 +0000 Subject: [PATCH 05/11] Refactor creation of HttpResponseMessage Refactor the creation of HttpResponseMessage to reduce cyclomatic complexity as warned about in the code coverage reports. --- .../HttpClientInterceptorOptions.cs | 106 +++++++++--------- 1 file changed, 55 insertions(+), 51 deletions(-) diff --git a/src/HttpClientInterception/HttpClientInterceptorOptions.cs b/src/HttpClientInterception/HttpClientInterceptorOptions.cs index b7e92198..612e2f97 100644 --- a/src/HttpClientInterception/HttpClientInterceptorOptions.cs +++ b/src/HttpClientInterception/HttpClientInterceptorOptions.cs @@ -349,57 +349,7 @@ public async virtual Task GetResponseAsync(HttpRequestMessa return null; } - 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); - } - - if (response.ContentHeaders != null) - { - foreach (var pair in response.ContentHeaders) - { - result.Content.Headers.Add(pair.Key, pair.Value); - } - } - - result.Content.Headers.ContentType = new MediaTypeHeaderValue(response.ContentMediaType); - - if (response.ResponseHeaders != null) - { - foreach (var pair in response.ResponseHeaders) - { - result.Headers.Add(pair.Key, pair.Value); - } - } - } - catch (Exception) - { - result.Dispose(); - throw; - } - - return result; + return await BuildResponseAsync(request, response); } /// @@ -455,6 +405,17 @@ private static string BuildKey(HttpInterceptionResponse interceptor) 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 @@ -483,6 +444,49 @@ private void ConfigureMatcherAndRegister(HttpInterceptionResponse 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 { private readonly HttpClientInterceptorOptions _parent; From 33e1c4538d704b3792d7569844a69992bbfd35ca Mon Sep 17 00:00:00 2001 From: martincostello Date: Sun, 4 Mar 2018 12:41:57 +0000 Subject: [PATCH 06/11] Bump minor version Bump version to 1.2.0. --- Directory.Build.props | 2 +- appveyor.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 From 0ddd84e36aacc51196bd4231669fa83fbb502781 Mon Sep 17 00:00:00 2001 From: martincostello Date: Sun, 4 Mar 2018 13:24:26 +0000 Subject: [PATCH 07/11] Fix not being able to have multiple predicate registrations Fix not being able to have more than one predicate-based HTTP interception registration due to all delegates having the same hash codes. --- src/HttpClientInterception/HttpClientInterceptorOptions.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/HttpClientInterception/HttpClientInterceptorOptions.cs b/src/HttpClientInterception/HttpClientInterceptorOptions.cs index 612e2f97..4206286a 100644 --- a/src/HttpClientInterception/HttpClientInterceptorOptions.cs +++ b/src/HttpClientInterception/HttpClientInterceptorOptions.cs @@ -384,7 +384,9 @@ private static string BuildKey(HttpInterceptionResponse interceptor) { if (interceptor.UserMatcher != null) { - return $"CUSTOM:{interceptor.UserMatcher.GetHashCode().ToString(CultureInfo.InvariantCulture)}"; + // 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); From e2b21932ae193c338e86c02dfa0ad2daeb26fae8 Mon Sep 17 00:00:00 2001 From: martincostello Date: Sun, 4 Mar 2018 13:27:07 +0000 Subject: [PATCH 08/11] Add convenience registration method to builder Add a RegisterWith() extension method to HttpRequestInterceptionBuilder to improve the fluent chaining to configure multiple registrations on the same builder. --- ...ttpRequestInterceptionBuilderExtensions.cs | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/HttpClientInterception/HttpRequestInterceptionBuilderExtensions.cs b/src/HttpClientInterception/HttpRequestInterceptionBuilderExtensions.cs index ad22ec22..2493c5da 100644 --- a/src/HttpClientInterception/HttpRequestInterceptionBuilderExtensions.cs +++ b/src/HttpClientInterception/HttpRequestInterceptionBuilderExtensions.cs @@ -230,5 +230,28 @@ public static HttpRequestInterceptionBuilder ForUrl(this HttpRequestInterception /// 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; + } } } From f1ddd3fbb56f7d8b7a5916f123a9e8c363348731 Mon Sep 17 00:00:00 2001 From: martincostello Date: Sun, 4 Mar 2018 13:37:07 +0000 Subject: [PATCH 09/11] Add concept of priority to HttpRequestInterceptionBuilder Add a concept of priority to HttpRequestInterceptionBuilder for use with predicate matching to allow a hierarchy of matches to be configured. The lower the value of priority, the more important the match is. --- .../HttpClientInterceptorOptions.cs | 2 ++ .../HttpInterceptionResponse.cs | 2 ++ .../HttpRequestInterceptionBuilder.cs | 27 +++++++++++++++ .../HttpClientInterception.Tests/Examples.cs | 33 +++++++++++++++++++ .../HttpRequestInterceptionBuilderTests.cs | 10 ++++++ 5 files changed, 74 insertions(+) diff --git a/src/HttpClientInterception/HttpClientInterceptorOptions.cs b/src/HttpClientInterception/HttpClientInterceptorOptions.cs index 4206286a..b7049f2f 100644 --- a/src/HttpClientInterception/HttpClientInterceptorOptions.cs +++ b/src/HttpClientInterception/HttpClientInterceptorOptions.cs @@ -421,6 +421,8 @@ private static void PopulateHeaders(HttpHeaders headers, IEnumerable p.Priority.HasValue) + .ThenBy((p) => p.Priority) .Where((p) => p.InternalMatcher.IsMatch(request)) .FirstOrDefault(); diff --git a/src/HttpClientInterception/HttpInterceptionResponse.cs b/src/HttpClientInterception/HttpInterceptionResponse.cs index 8272083b..150cb06d 100644 --- a/src/HttpClientInterception/HttpInterceptionResponse.cs +++ b/src/HttpClientInterception/HttpInterceptionResponse.cs @@ -18,6 +18,8 @@ internal sealed class HttpInterceptionResponse internal HttpMethod Method { get; set; } + internal int? Priority { get; set; } + internal string ReasonPhrase { get; set; } internal Uri RequestUri { get; set; } diff --git a/src/HttpClientInterception/HttpRequestInterceptionBuilder.cs b/src/HttpClientInterception/HttpRequestInterceptionBuilder.cs index 58df89a4..48d38485 100644 --- a/src/HttpClientInterception/HttpRequestInterceptionBuilder.cs +++ b/src/HttpClientInterception/HttpRequestInterceptionBuilder.cs @@ -47,6 +47,8 @@ public class HttpRequestInterceptionBuilder private bool _ignoreQuery; + private int? _priority; + /// /// Configures the builder to match any request that meets the criteria defined by the specified predicate. /// @@ -583,6 +585,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() @@ -594,6 +620,7 @@ internal HttpInterceptionResponse Build() IgnoreQuery = _ignoreQuery, Method = _method, OnIntercepted = _onIntercepted, + Priority = _priority, ReasonPhrase = _reasonPhrase, RequestUri = _uriBuilder.Uri, StatusCode = _statusCode, diff --git a/tests/HttpClientInterception.Tests/Examples.cs b/tests/HttpClientInterception.Tests/Examples.cs index 343fcbd8..a79f8aef 100644 --- a/tests/HttpClientInterception.Tests/Examples.cs +++ b/tests/HttpClientInterception.Tests/Examples.cs @@ -592,5 +592,38 @@ public static async Task Use_Custom_Request_Matching() (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/HttpRequestInterceptionBuilderTests.cs b/tests/HttpClientInterception.Tests/HttpRequestInterceptionBuilderTests.cs index a32698e1..aabbcfe9 100644 --- a/tests/HttpClientInterception.Tests/HttpRequestInterceptionBuilderTests.cs +++ b/tests/HttpClientInterception.Tests/HttpRequestInterceptionBuilderTests.cs @@ -993,6 +993,16 @@ public static async Task Builder_Returns_Fallback_For_Missing_Registration() 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 From 57d0809cdb444f3898467b55eb512b0098a9c740 Mon Sep 17 00:00:00 2001 From: martincostello Date: Sun, 4 Mar 2018 13:52:27 +0000 Subject: [PATCH 10/11] Update test dependencies Update the dependencies for the test projects. --- samples/SampleApp.Tests/SampleApp.Tests.csproj | 3 +-- samples/SampleApp/SampleApp.csproj | 1 - .../JustEat.HttpClientInterception.Benchmarks.csproj | 4 ++-- .../JustEat.HttpClientInterception.Tests.csproj | 4 ++-- 4 files changed, 5 insertions(+), 7 deletions(-) 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/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/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 @@ - + - + From 904dc307fd251f989196fc9346ad1e3541f4e4a2 Mon Sep 17 00:00:00 2001 From: martincostello Date: Tue, 6 Mar 2018 14:11:01 +0000 Subject: [PATCH 11/11] Add support for ignoring paths Add support for ignoring paths on registrations. --- .../HttpClientInterceptorOptions.cs | 6 +++ .../HttpInterceptionResponse.cs | 2 + .../HttpRequestInterceptionBuilder.cs | 14 ++++++ .../Matching/RegistrationMatcher.cs | 5 +++ .../HttpRequestInterceptionBuilderTests.cs | 43 +++++++++++++++++-- 5 files changed, 67 insertions(+), 3 deletions(-) diff --git a/src/HttpClientInterception/HttpClientInterceptorOptions.cs b/src/HttpClientInterception/HttpClientInterceptorOptions.cs index b7049f2f..628697f7 100644 --- a/src/HttpClientInterception/HttpClientInterceptorOptions.cs +++ b/src/HttpClientInterception/HttpClientInterceptorOptions.cs @@ -398,6 +398,12 @@ private static string BuildKey(HttpInterceptionResponse interceptor) keyPrefix = "IGNOREHOST;"; } + if (interceptor.IgnorePath) + { + builderForKey.Path = string.Empty; + keyPrefix += "IGNOREPATH;"; + } + if (interceptor.IgnoreQuery) { builderForKey.Query = string.Empty; diff --git a/src/HttpClientInterception/HttpInterceptionResponse.cs b/src/HttpClientInterception/HttpInterceptionResponse.cs index 150cb06d..70361d99 100644 --- a/src/HttpClientInterception/HttpInterceptionResponse.cs +++ b/src/HttpClientInterception/HttpInterceptionResponse.cs @@ -36,6 +36,8 @@ internal sealed class HttpInterceptionResponse 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 48d38485..9f90f2cd 100644 --- a/src/HttpClientInterception/HttpRequestInterceptionBuilder.cs +++ b/src/HttpClientInterception/HttpRequestInterceptionBuilder.cs @@ -45,6 +45,8 @@ public class HttpRequestInterceptionBuilder private bool _ignoreHost; + private bool _ignorePath; + private bool _ignoreQuery; private int? _priority; @@ -162,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. /// @@ -617,6 +630,7 @@ internal HttpInterceptionResponse Build() ContentStream = _contentStream, ContentMediaType = _mediaType, IgnoreHost = _ignoreHost, + IgnorePath = _ignorePath, IgnoreQuery = _ignoreQuery, Method = _method, OnIntercepted = _onIntercepted, diff --git a/src/HttpClientInterception/Matching/RegistrationMatcher.cs b/src/HttpClientInterception/Matching/RegistrationMatcher.cs index 1bb4df64..fa13560b 100644 --- a/src/HttpClientInterception/Matching/RegistrationMatcher.cs +++ b/src/HttpClientInterception/Matching/RegistrationMatcher.cs @@ -70,6 +70,11 @@ private static string GetUriStringForMatch(HttpInterceptionResponse registration builder.Host = "*"; } + if (registration.IgnorePath) + { + builder.Path = string.Empty; + } + if (registration.IgnoreQuery) { builder.Query = string.Empty; diff --git a/tests/HttpClientInterception.Tests/HttpRequestInterceptionBuilderTests.cs b/tests/HttpClientInterception.Tests/HttpRequestInterceptionBuilderTests.cs index aabbcfe9..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() { @@ -955,21 +991,22 @@ public static async Task Builder_For_Any_Host_Registers_Interception() } [Fact] - public static async Task Builder_For_Any_Host_And_Query_Registers_Interception() + 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/?foo=bar"); - string actual2 = await HttpAssert.GetAsync(options, "http://bing.com/?foo=baz"); + 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);