Skip to content

Commit 0d1e8e6

Browse files
Fix content negotiation
Fix inability to return different responses based on content negotiation via HTTP request headers by including a hash of their names and values in the key for the registration.
1 parent 6e813fa commit 0d1e8e6

File tree

3 files changed

+176
-7
lines changed

3 files changed

+176
-7
lines changed

src/HttpClientInterception/HttpClientInterceptorOptions.cs

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -438,7 +438,79 @@ private static string BuildKey(HttpInterceptionResponse interceptor)
438438
builderForKey.Port = -1;
439439
}
440440

441-
return $"{keyPrefix};{interceptor.Method!.Method}:{builderForKey}";
441+
string keySuffix = ComputeKeyForHeaders(
442+
interceptor.ContentHeaders,
443+
interceptor.RequestHeaders);
444+
445+
return $"{keyPrefix};{interceptor.Method!.Method}:{builderForKey};{keySuffix}";
446+
}
447+
448+
private static string ComputeKeyForHeaders(
449+
IEnumerable<KeyValuePair<string, IEnumerable<string>>>? contentHeaders,
450+
IEnumerable<KeyValuePair<string, IEnumerable<string>>>? requestHeaders)
451+
{
452+
List<KeyValuePair<string, IEnumerable<string>>> headers = [];
453+
454+
// DynamicDictionary is excluded as it is not consider to be immutable so we cannot
455+
// use it to create a hash suffix as it might change later and invalidate the key.
456+
if (contentHeaders is { } and not HttpRequestInterceptionBuilder.DynamicDictionary)
457+
{
458+
headers.AddRange(contentHeaders);
459+
}
460+
461+
if (requestHeaders is { } and not HttpRequestInterceptionBuilder.DynamicDictionary)
462+
{
463+
headers.AddRange(requestHeaders);
464+
}
465+
466+
if (headers.Count < 1)
467+
{
468+
return string.Empty;
469+
}
470+
471+
int hashCode;
472+
473+
#if NET8_0
474+
var combiner = new HashCode();
475+
476+
foreach (var pair in headers)
477+
{
478+
// Treat the headers as case-insensitive like HTTP does
479+
combiner.Add(pair.Key, StringComparer.OrdinalIgnoreCase);
480+
481+
if (pair.Value is { } values)
482+
{
483+
foreach (var value in values)
484+
{
485+
combiner.Add(value);
486+
}
487+
}
488+
}
489+
490+
hashCode = combiner.ToHashCode();
491+
#else
492+
// Copied from https://referencesource.microsoft.com/#mscorlib/system/array.cs,809
493+
hashCode = string.Empty.GetHashCode();
494+
495+
foreach (var pair in headers)
496+
{
497+
// Treat the headers as case-insensitive like HTTP does
498+
hashCode = CombineHashCode(hashCode, pair.Key.ToUpperInvariant());
499+
500+
if (pair.Value is { } values)
501+
{
502+
foreach (var value in values)
503+
{
504+
hashCode = CombineHashCode(hashCode, value);
505+
}
506+
}
507+
}
508+
509+
static int CombineHashCode(int hc, string value) => CombineHashCodes(hc, value.GetHashCode());
510+
static int CombineHashCodes(int a, int b) => ((a << 5) + a) ^ b;
511+
#endif
512+
513+
return hashCode.ToString(CultureInfo.InvariantCulture);
442514
}
443515

444516
private static void PopulateHeaders(HttpHeaders headers, IEnumerable<KeyValuePair<string, IEnumerable<string>>>? values)

src/HttpClientInterception/HttpRequestInterceptionBuilder.cs

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -472,7 +472,7 @@ public HttpRequestInterceptionBuilder WithContentHeader(string name, IEnumerable
472472

473473
if (!_contentHeaders.TryGetValue(name, out var current))
474474
{
475-
_contentHeaders[name] = current = new List<string>();
475+
_contentHeaders[name] = current = [];
476476
}
477477

478478
current.Clear();
@@ -617,7 +617,7 @@ public HttpRequestInterceptionBuilder WithResponseHeader(string name, IEnumerabl
617617

618618
if (!_responseHeaders.TryGetValue(name, out ICollection<string>? current))
619619
{
620-
_responseHeaders[name] = current = new List<string>();
620+
_responseHeaders[name] = current = [];
621621
}
622622

623623
current.Clear();
@@ -955,7 +955,7 @@ public HttpRequestInterceptionBuilder ForRequestHeader(string name, IEnumerable<
955955

956956
if (!_requestHeaders.TryGetValue(name, out ICollection<string>? current))
957957
{
958-
_requestHeaders[name] = current = new List<string>();
958+
_requestHeaders[name] = current = [];
959959
}
960960

961961
current.Clear();
@@ -1108,7 +1108,7 @@ internal HttpInterceptionResponse Build()
11081108

11091109
foreach (var pair in _requestHeaders)
11101110
{
1111-
headers[pair.Key] = pair.Value;
1111+
headers[pair.Key] = [.. pair.Value];
11121112
}
11131113

11141114
response.RequestHeaders = headers;
@@ -1127,7 +1127,7 @@ internal HttpInterceptionResponse Build()
11271127

11281128
foreach (var pair in _responseHeaders)
11291129
{
1130-
headers[pair.Key] = pair.Value;
1130+
headers[pair.Key] = [.. pair.Value];
11311131
}
11321132

11331133
response.ResponseHeaders = headers;
@@ -1171,7 +1171,7 @@ private void IncrementRevision()
11711171
}
11721172
}
11731173

1174-
private sealed class DynamicDictionary :
1174+
internal sealed class DynamicDictionary :
11751175
IDictionary<string, ICollection<string>>,
11761176
IEnumerable<KeyValuePair<string, IEnumerable<string>>>
11771177
{

tests/HttpClientInterception.Tests/Examples.cs

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -889,6 +889,103 @@ public static async Task Intercept_Http_Get_For_Json_Object_With_Json_Source_Gen
889889
content.Link.ShouldBe("https://www.just-eat.co.uk/privacy-policy");
890890
}
891891

892+
[Fact]
893+
public static async Task Vary_The_Response_Using_Content_Negotiation()
894+
{
895+
// Arrange
896+
var expectedPull =
897+
"""
898+
{
899+
"url": "https://api.github.com/repos/justeattakeaway/httpclient-interception/pulls/1004",
900+
"id": 2346177741,
901+
"node_id": "PR_kwDOBa7mxs6L19TN",
902+
"html_url": "https://github.com/justeattakeaway/httpclient-interception/pull/1004",
903+
"diff_url": "https://github.com/justeattakeaway/httpclient-interception/pull/1004.diff",
904+
"patch_url": "https://github.com/justeattakeaway/httpclient-interception/pull/1004.patch",
905+
"issue_url": "https://api.github.com/repos/justeattakeaway/httpclient-interception/issues/1004",
906+
"number": 1004,
907+
"state": "closed",
908+
"title": "Bump the xunit group with 2 updates",
909+
"user": {
910+
"login": "dependabot[bot]"
911+
},
912+
"body": "Bumps the xunit group with 2 updates: [xunit.runner.visualstudio](https://github.com/xunit/visualstudio.xunit) and [xunit.v3](https://github.com/xunit/xunit).",
913+
"created_at": "2025-02-20T05:25:35Z",
914+
"updated_at": "2025-02-20T06:30:51Z",
915+
"closed_at": "2025-02-20T06:30:45Z",
916+
"merged_at": "2025-02-20T06:30:45Z",
917+
"merge_commit_sha": "97e1bfe247e3d79d5235a60b1725a6de44fa9411",
918+
"draft": false,
919+
"author_association": "CONTRIBUTOR",
920+
"merged": true
921+
}
922+
""";
923+
924+
var expectedDiff =
925+
"""
926+
diff --git a/Directory.Packages.props b/Directory.Packages.props
927+
index 39a1334b..165c7500 100644
928+
--- a/Directory.Packages.props
929+
+++ b/Directory.Packages.props
930+
@@ -19,8 +19,8 @@
931+
<PackageVersion Include="StyleCop.Analyzers" Version="1.2.0-beta.556" />
932+
<PackageVersion Include="System.Net.Http" Version="4.3.4" />
933+
<PackageVersion Include="System.Text.Json" Version="8.0.5" />
934+
- <PackageVersion Include="xunit.runner.visualstudio" Version="3.0.1" />
935+
- <PackageVersion Include="xunit.v3" Version="1.0.1" />
936+
+ <PackageVersion Include="xunit.runner.visualstudio" Version="3.0.2" />
937+
+ <PackageVersion Include="xunit.v3" Version="1.1.0" />
938+
</ItemGroup>
939+
<ItemGroup>
940+
<PackageReference Include="Microsoft.CodeAnalysis.PublicApiAnalyzers" PrivateAssets="All" />
941+
""";
942+
943+
var cancellationToken = TestContext.Current.CancellationToken;
944+
var options = new HttpClientInterceptorOptions().ThrowsOnMissingRegistration();
945+
946+
var builder = new HttpRequestInterceptionBuilder()
947+
.ForHttps()
948+
.ForHost("api.github.com")
949+
.ForPath("repos/justeattakeaway/httpclient-interception/pulls/1004")
950+
.ForRequestHeader("Accept", "application/vnd.github.v3+json")
951+
.WithContentHeader("Content-Type", "application/json; charset=utf-8")
952+
.WithContent(expectedPull);
953+
954+
options.Register(builder);
955+
956+
builder.ForRequestHeader("Accept", "application/vnd.github.diff")
957+
.Responds()
958+
.WithContentHeader("Content-Type", "application/vnd.github.diff; charset=utf-8")
959+
.WithContent(expectedDiff);
960+
961+
options.Register(builder);
962+
963+
string requestUri = "/repos/justeattakeaway/httpclient-interception/pulls/1004";
964+
965+
using var client = options.CreateHttpClient("https://api.github.com");
966+
967+
using var pullRequest = new HttpRequestMessage(HttpMethod.Get, requestUri);
968+
pullRequest.Headers.Accept.Add(new("application/vnd.github.v3+json"));
969+
970+
// Act
971+
using var pull = await client.SendAsync(pullRequest, cancellationToken);
972+
var actualPull = await pull.Content.ReadAsStringAsync(cancellationToken);
973+
974+
// Assert
975+
actualPull.ShouldBe(expectedPull, StringCompareShould.IgnoreLineEndings);
976+
977+
// Arrange
978+
using var diffRequest = new HttpRequestMessage(HttpMethod.Get, requestUri);
979+
diffRequest.Headers.Accept.Add(new("application/vnd.github.diff"));
980+
981+
// Act
982+
using var diff = await client.SendAsync(diffRequest, cancellationToken);
983+
var actualDiff = await diff.Content.ReadAsStringAsync(cancellationToken);
984+
985+
// Assert
986+
actualDiff.ShouldBe(expectedDiff, StringCompareShould.IgnoreLineEndings);
987+
}
988+
892989
private sealed class TermsAndConditions
893990
{
894991
[JsonPropertyName("Id")]

0 commit comments

Comments
 (0)