44using System ;
55using System . Collections . Concurrent ;
66using System . Collections . Generic ;
7+ using System . Globalization ;
78using System . IO ;
9+ using System . Linq ;
810using System . Net ;
911using System . Net . Http ;
1012using System . Net . Http . Headers ;
1113using System . Threading ;
1214using System . Threading . Tasks ;
15+ using JustEat . HttpClientInterception . Matching ;
1316
1417namespace 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