Skip to content

Commit

Permalink
Merge pull request #21 from martincostello/Support-Matching-Any-Host
Browse files Browse the repository at this point in the history
Support matching any host and providing fallback responses
  • Loading branch information
martincostello authored Mar 7, 2018
2 parents 204e16d + 904dc30 commit ab741bf
Show file tree
Hide file tree
Showing 19 changed files with 771 additions and 95 deletions.
2 changes: 1 addition & 1 deletion Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
<RepositoryType>git</RepositoryType>
<RepositoryUrl>$(PackageProjectUrl).git</RepositoryUrl>
<TreatWarningsAsErrors>True</TreatWarningsAsErrors>
<VersionPrefix>1.1.1</VersionPrefix>
<VersionPrefix>1.2.0</VersionPrefix>
<VersionSuffix></VersionSuffix>
</PropertyGroup>
</Project>
2 changes: 1 addition & 1 deletion appveyor.yml
Original file line number Diff line number Diff line change
@@ -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
Expand Down
3 changes: 1 addition & 2 deletions samples/SampleApp.Tests/SampleApp.Tests.csproj
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<LangVersion>latest</LangVersion>
<TargetFramework>netcoreapp2.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
Expand All @@ -9,7 +8,7 @@
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Hosting" Version="2.0.1" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="2.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="15.5.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="15.6.0" />
<PackageReference Include="Shouldly" Version="3.0.0" />
<PackageReference Include="xunit" Version="2.3.1" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.3.1" />
Expand Down
1 change: 0 additions & 1 deletion samples/SampleApp/SampleApp.csproj
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<LangVersion>latest</LangVersion>
<TargetFramework>netcoreapp2.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
Expand Down
247 changes: 167 additions & 80 deletions src/HttpClientInterception/HttpClientInterceptorOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -24,9 +27,9 @@ public class HttpClientInterceptorOptions
internal const string JsonMediaType = "application/json";

/// <summary>
/// The <see cref="StringComparer"/> to use to key registrations. This field is read-only.
/// The <see cref="StringComparer"/> to use for key registrations.
/// </summary>
private readonly StringComparer _comparer;
private StringComparer _comparer;

/// <summary>
/// The mapped HTTP request interceptors.
Expand All @@ -47,13 +50,19 @@ public HttpClientInterceptorOptions()
/// <summary>
/// Initializes a new instance of the <see cref="HttpClientInterceptorOptions"/> class.
/// </summary>
/// <param name="caseSensitive">Whether registered URIs paths and queries are case-sensitive.</param>
/// <param name="caseSensitive">Whether registered URIs' paths and queries are case-sensitive.</param>
public HttpClientInterceptorOptions(bool caseSensitive)
{
_comparer = caseSensitive ? StringComparer.Ordinal : StringComparer.OrdinalIgnoreCase;
_mappings = new ConcurrentDictionary<string, HttpInterceptionResponse>(_comparer);
}

/// <summary>
/// Gets or sets an optional delegate to invoke when an HTTP request does not match an existing
/// registration, which optionally returns an <see cref="HttpResponseMessage"/> to use.
/// </summary>
public Func<HttpRequestMessage, Task<HttpResponseMessage>> OnMissingRegistration { get; set; }

/// <summary>
/// Gets or sets an optional delegate to invoke when an HTTP request is sent.
/// </summary>
Expand Down Expand Up @@ -99,6 +108,7 @@ public HttpClientInterceptorOptions Clone()
ThrowOnMissingRegistration = ThrowOnMissingRegistration
};

clone._comparer = _comparer;
clone._mappings = new ConcurrentDictionary<string, HttpInterceptionResponse>(_mappings, _comparer);

return clone;
Expand Down Expand Up @@ -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;
}

/// <summary>
/// Deregisters an existing HTTP request interception, if it exists.
/// </summary>
/// <param name="builder">The HTTP interception to deregister.</param>
/// <returns>
/// The current <see cref="HttpClientInterceptorOptions"/>.
/// </returns>
/// <exception cref="ArgumentNullException">
/// <paramref name="builder"/> is <see langword="null"/>.
/// </exception>
/// <remarks>
/// If <paramref name="builder"/> has been reconfigured since it was used
/// to register a previous HTTP request interception it will not remove that
/// registration. In such cases, use <see cref="Clear"/>.
/// </remarks>
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;
Expand Down Expand Up @@ -187,8 +233,7 @@ public HttpClientInterceptorOptions Register(
StatusCode = statusCode
};

string key = BuildKey(method, uri);
_mappings[key] = interceptor;
ConfigureMatcherAndRegister(interceptor);

return this;
}
Expand Down Expand Up @@ -247,8 +292,7 @@ public HttpClientInterceptorOptions Register(
StatusCode = statusCode
};

string key = BuildKey(method, uri);
_mappings[key] = interceptor;
ConfigureMatcherAndRegister(interceptor);

return this;
}
Expand All @@ -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;
}
Expand All @@ -296,74 +339,17 @@ public async virtual Task<HttpResponseMessage> 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<byte>();
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);
}

/// <summary>
Expand All @@ -388,26 +374,127 @@ public virtual HttpClient CreateHttpClient(HttpMessageHandler innerHandler = nul
}

/// <summary>
/// Builds the mapping key to use for the specified HTTP request.
/// Builds the mapping key to use for the specified intercepted HTTP request.
/// </summary>
/// <param name="method">The HTTP method.</param>
/// <param name="uri">The HTTP request URI.</param>
/// <param name="ignoreQueryString">If true create a key without any query string but with an extra string to disambiguate.</param>
/// <param name="interceptor">The configured HTTP interceptor.</param>
/// <returns>
/// A <see cref="string"/> to use as the key for the interceptor registration.
/// </returns>
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<KeyValuePair<string, IEnumerable<string>>> 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<HttpResponseMessage> 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<byte>();
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
Expand Down
Loading

0 comments on commit ab741bf

Please sign in to comment.