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 @@ - + - +