Skip to content

Commit ab741bf

Browse files
Merge pull request #21 from martincostello/Support-Matching-Any-Host
Support matching any host and providing fallback responses
2 parents 204e16d + 904dc30 commit ab741bf

19 files changed

+771
-95
lines changed

Directory.Build.props

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
<RepositoryType>git</RepositoryType>
2525
<RepositoryUrl>$(PackageProjectUrl).git</RepositoryUrl>
2626
<TreatWarningsAsErrors>True</TreatWarningsAsErrors>
27-
<VersionPrefix>1.1.1</VersionPrefix>
27+
<VersionPrefix>1.2.0</VersionPrefix>
2828
<VersionSuffix></VersionSuffix>
2929
</PropertyGroup>
3030
</Project>

appveyor.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
os: Visual Studio 2017
2-
version: 1.1.1.{build}
2+
version: 1.2.0.{build}
33

44
environment:
55
DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true

samples/SampleApp.Tests/SampleApp.Tests.csproj

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
<Project Sdk="Microsoft.NET.Sdk">
22
<PropertyGroup>
3-
<LangVersion>latest</LangVersion>
43
<TargetFramework>netcoreapp2.0</TargetFramework>
54
</PropertyGroup>
65
<ItemGroup>
@@ -9,7 +8,7 @@
98
<ItemGroup>
109
<PackageReference Include="Microsoft.AspNetCore.Hosting" Version="2.0.1" />
1110
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="2.0.0" />
12-
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="15.5.0" />
11+
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="15.6.0" />
1312
<PackageReference Include="Shouldly" Version="3.0.0" />
1413
<PackageReference Include="xunit" Version="2.3.1" />
1514
<PackageReference Include="xunit.runner.visualstudio" Version="2.3.1" />

samples/SampleApp/SampleApp.csproj

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
<Project Sdk="Microsoft.NET.Sdk.Web">
22
<PropertyGroup>
3-
<LangVersion>latest</LangVersion>
43
<TargetFramework>netcoreapp2.0</TargetFramework>
54
</PropertyGroup>
65
<ItemGroup>

src/HttpClientInterception/HttpClientInterceptorOptions.cs

Lines changed: 167 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,15 @@
44
using System;
55
using System.Collections.Concurrent;
66
using System.Collections.Generic;
7+
using System.Globalization;
78
using System.IO;
9+
using System.Linq;
810
using System.Net;
911
using System.Net.Http;
1012
using System.Net.Http.Headers;
1113
using System.Threading;
1214
using System.Threading.Tasks;
15+
using JustEat.HttpClientInterception.Matching;
1316

1417
namespace JustEat.HttpClientInterception
1518
{
@@ -24,9 +27,9 @@ public class HttpClientInterceptorOptions
2427
internal const string JsonMediaType = "application/json";
2528

2629
/// <summary>
27-
/// The <see cref="StringComparer"/> to use to key registrations. This field is read-only.
30+
/// The <see cref="StringComparer"/> to use for key registrations.
2831
/// </summary>
29-
private readonly StringComparer _comparer;
32+
private StringComparer _comparer;
3033

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

60+
/// <summary>
61+
/// Gets or sets an optional delegate to invoke when an HTTP request does not match an existing
62+
/// registration, which optionally returns an <see cref="HttpResponseMessage"/> to use.
63+
/// </summary>
64+
public Func<HttpRequestMessage, Task<HttpResponseMessage>> OnMissingRegistration { get; set; }
65+
5766
/// <summary>
5867
/// Gets or sets an optional delegate to invoke when an HTTP request is sent.
5968
/// </summary>
@@ -99,6 +108,7 @@ public HttpClientInterceptorOptions Clone()
99108
ThrowOnMissingRegistration = ThrowOnMissingRegistration
100109
};
101110

111+
clone._comparer = _comparer;
102112
clone._mappings = new ConcurrentDictionary<string, HttpInterceptionResponse>(_mappings, _comparer);
103113

104114
return clone;
@@ -127,7 +137,43 @@ public HttpClientInterceptorOptions Deregister(HttpMethod method, Uri uri)
127137
throw new ArgumentNullException(nameof(uri));
128138
}
129139

130-
string key = BuildKey(method, uri);
140+
var interceptor = new HttpInterceptionResponse()
141+
{
142+
Method = method,
143+
RequestUri = uri,
144+
};
145+
146+
string key = BuildKey(interceptor);
147+
_mappings.Remove(key);
148+
149+
return this;
150+
}
151+
152+
/// <summary>
153+
/// Deregisters an existing HTTP request interception, if it exists.
154+
/// </summary>
155+
/// <param name="builder">The HTTP interception to deregister.</param>
156+
/// <returns>
157+
/// The current <see cref="HttpClientInterceptorOptions"/>.
158+
/// </returns>
159+
/// <exception cref="ArgumentNullException">
160+
/// <paramref name="builder"/> is <see langword="null"/>.
161+
/// </exception>
162+
/// <remarks>
163+
/// If <paramref name="builder"/> has been reconfigured since it was used
164+
/// to register a previous HTTP request interception it will not remove that
165+
/// registration. In such cases, use <see cref="Clear"/>.
166+
/// </remarks>
167+
public HttpClientInterceptorOptions Deregister(HttpRequestInterceptionBuilder builder)
168+
{
169+
if (builder == null)
170+
{
171+
throw new ArgumentNullException(nameof(builder));
172+
}
173+
174+
HttpInterceptionResponse interceptor = builder.Build();
175+
176+
string key = BuildKey(interceptor);
131177
_mappings.Remove(key);
132178

133179
return this;
@@ -187,8 +233,7 @@ public HttpClientInterceptorOptions Register(
187233
StatusCode = statusCode
188234
};
189235

190-
string key = BuildKey(method, uri);
191-
_mappings[key] = interceptor;
236+
ConfigureMatcherAndRegister(interceptor);
192237

193238
return this;
194239
}
@@ -247,8 +292,7 @@ public HttpClientInterceptorOptions Register(
247292
StatusCode = statusCode
248293
};
249294

250-
string key = BuildKey(method, uri);
251-
_mappings[key] = interceptor;
295+
ConfigureMatcherAndRegister(interceptor);
252296

253297
return this;
254298
}
@@ -272,8 +316,7 @@ public HttpClientInterceptorOptions Register(HttpRequestInterceptionBuilder buil
272316

273317
HttpInterceptionResponse interceptor = builder.Build();
274318

275-
string key = BuildKey(interceptor.Method, interceptor.RequestUri, interceptor.IgnoreQuery);
276-
_mappings[key] = interceptor;
319+
ConfigureMatcherAndRegister(interceptor);
277320

278321
return this;
279322
}
@@ -296,74 +339,17 @@ public async virtual Task<HttpResponseMessage> GetResponseAsync(HttpRequestMessa
296339
throw new ArgumentNullException(nameof(request));
297340
}
298341

299-
string key = BuildKey(request.Method, request.RequestUri, ignoreQueryString: false);
300-
301-
if (!_mappings.TryGetValue(key, out HttpInterceptionResponse options))
302-
{
303-
string keyWithoutQueryString = BuildKey(request.Method, request.RequestUri, ignoreQueryString: true);
304-
305-
if (!_mappings.TryGetValue(keyWithoutQueryString, out options))
306-
{
307-
return null;
308-
}
309-
}
310-
311-
if (options.OnIntercepted != null && !await options.OnIntercepted(request))
342+
if (!TryGetResponse(request, out HttpInterceptionResponse response))
312343
{
313344
return null;
314345
}
315346

316-
var result = new HttpResponseMessage(options.StatusCode);
317-
318-
try
319-
{
320-
result.RequestMessage = request;
321-
322-
if (options.ReasonPhrase != null)
323-
{
324-
result.ReasonPhrase = options.ReasonPhrase;
325-
}
326-
327-
if (options.Version != null)
328-
{
329-
result.Version = options.Version;
330-
}
331-
332-
if (options.ContentStream != null)
333-
{
334-
result.Content = new StreamContent(await options.ContentStream() ?? Stream.Null);
335-
}
336-
else
337-
{
338-
byte[] content = await options.ContentFactory() ?? Array.Empty<byte>();
339-
result.Content = new ByteArrayContent(content);
340-
}
341-
342-
if (options.ContentHeaders != null)
343-
{
344-
foreach (var pair in options.ContentHeaders)
345-
{
346-
result.Content.Headers.Add(pair.Key, pair.Value);
347-
}
348-
}
349-
350-
result.Content.Headers.ContentType = new MediaTypeHeaderValue(options.ContentMediaType);
351-
352-
if (options.ResponseHeaders != null)
353-
{
354-
foreach (var pair in options.ResponseHeaders)
355-
{
356-
result.Headers.Add(pair.Key, pair.Value);
357-
}
358-
}
359-
}
360-
catch (Exception)
347+
if (response.OnIntercepted != null && !await response.OnIntercepted(request))
361348
{
362-
result.Dispose();
363-
throw;
349+
return null;
364350
}
365351

366-
return result;
352+
return await BuildResponseAsync(request, response);
367353
}
368354

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

390376
/// <summary>
391-
/// Builds the mapping key to use for the specified HTTP request.
377+
/// Builds the mapping key to use for the specified intercepted HTTP request.
392378
/// </summary>
393-
/// <param name="method">The HTTP method.</param>
394-
/// <param name="uri">The HTTP request URI.</param>
395-
/// <param name="ignoreQueryString">If true create a key without any query string but with an extra string to disambiguate.</param>
379+
/// <param name="interceptor">The configured HTTP interceptor.</param>
396380
/// <returns>
397381
/// A <see cref="string"/> to use as the key for the interceptor registration.
398382
/// </returns>
399-
private static string BuildKey(HttpMethod method, Uri uri, bool ignoreQueryString = false)
383+
private static string BuildKey(HttpInterceptionResponse interceptor)
400384
{
401-
if (ignoreQueryString)
385+
if (interceptor.UserMatcher != null)
402386
{
403-
var uriWithoutQueryString = uri == null ? null : new UriBuilder(uri) { Query = string.Empty }.Uri;
387+
// Use the internal matcher's hash code as UserMatcher (a delegate)
388+
// will always return the hash code. See https://stackoverflow.com/q/6624151/1064169
389+
return $"CUSTOM:{interceptor.InternalMatcher.GetHashCode().ToString(CultureInfo.InvariantCulture)}";
390+
}
391+
392+
var builderForKey = new UriBuilder(interceptor.RequestUri);
393+
string keyPrefix = string.Empty;
404394

405-
return $"{method.Method}:IGNOREQUERY:{uriWithoutQueryString}";
395+
if (interceptor.IgnoreHost)
396+
{
397+
builderForKey.Host = "*";
398+
keyPrefix = "IGNOREHOST;";
399+
}
400+
401+
if (interceptor.IgnorePath)
402+
{
403+
builderForKey.Path = string.Empty;
404+
keyPrefix += "IGNOREPATH;";
405+
}
406+
407+
if (interceptor.IgnoreQuery)
408+
{
409+
builderForKey.Query = string.Empty;
410+
keyPrefix += "IGNOREQUERY;";
411+
}
412+
413+
return $"{keyPrefix};{interceptor.Method.Method}:{builderForKey}";
414+
}
415+
416+
private static void PopulateHeaders(HttpHeaders headers, IEnumerable<KeyValuePair<string, IEnumerable<string>>> values)
417+
{
418+
if (values != null)
419+
{
420+
foreach (var pair in values)
421+
{
422+
headers.Add(pair.Key, pair.Value);
423+
}
424+
}
425+
}
426+
427+
private bool TryGetResponse(HttpRequestMessage request, out HttpInterceptionResponse response)
428+
{
429+
response = _mappings.Values
430+
.OrderByDescending((p) => p.Priority.HasValue)
431+
.ThenBy((p) => p.Priority)
432+
.Where((p) => p.InternalMatcher.IsMatch(request))
433+
.FirstOrDefault();
434+
435+
return response != null;
436+
}
437+
438+
private void ConfigureMatcherAndRegister(HttpInterceptionResponse registration)
439+
{
440+
RequestMatcher matcher;
441+
442+
if (registration.UserMatcher != null)
443+
{
444+
matcher = new DelegatingMatcher(registration.UserMatcher);
406445
}
407446
else
408447
{
409-
return $"{method.Method}:{uri}";
448+
matcher = new RegistrationMatcher(registration, _comparer);
410449
}
450+
451+
registration.InternalMatcher = matcher;
452+
453+
string key = BuildKey(registration);
454+
_mappings[key] = registration;
455+
}
456+
457+
private async Task<HttpResponseMessage> BuildResponseAsync(HttpRequestMessage request, HttpInterceptionResponse response)
458+
{
459+
var result = new HttpResponseMessage(response.StatusCode);
460+
461+
try
462+
{
463+
result.RequestMessage = request;
464+
465+
if (response.ReasonPhrase != null)
466+
{
467+
result.ReasonPhrase = response.ReasonPhrase;
468+
}
469+
470+
if (response.Version != null)
471+
{
472+
result.Version = response.Version;
473+
}
474+
475+
if (response.ContentStream != null)
476+
{
477+
result.Content = new StreamContent(await response.ContentStream() ?? Stream.Null);
478+
}
479+
else
480+
{
481+
byte[] content = await response.ContentFactory() ?? Array.Empty<byte>();
482+
result.Content = new ByteArrayContent(content);
483+
}
484+
485+
PopulateHeaders(result.Content.Headers, response.ContentHeaders);
486+
487+
result.Content.Headers.ContentType = new MediaTypeHeaderValue(response.ContentMediaType);
488+
489+
PopulateHeaders(result.Headers, response.ResponseHeaders);
490+
}
491+
catch (Exception)
492+
{
493+
result.Dispose();
494+
throw;
495+
}
496+
497+
return result;
411498
}
412499

413500
private sealed class OptionsScope : IDisposable

0 commit comments

Comments
 (0)