| | 1 | | // Copyright (c) Microsoft Corporation. All rights reserved. |
| | 2 | | // Licensed under the MIT License. |
| | 3 | |
|
| | 4 | | using System; |
| | 5 | | using System.Collections.Generic; |
| | 6 | | using System.Collections.Specialized; |
| | 7 | | using System.Linq; |
| | 8 | | using System.Text; |
| | 9 | | using System.Web; |
| | 10 | |
|
| | 11 | | namespace Azure.Core.TestFramework |
| | 12 | | { |
| | 13 | | public class RecordMatcher |
| | 14 | | { |
| | 15 | | // Headers that are normalized by HttpClient |
| 1394 | 16 | | private HashSet<string> _normalizedHeaders = new HashSet<string>(StringComparer.OrdinalIgnoreCase) |
| 1394 | 17 | | { |
| 1394 | 18 | | "Accept", |
| 1394 | 19 | | "Content-Type" |
| 1394 | 20 | | }; |
| | 21 | |
|
| | 22 | | private bool _compareBodies; |
| | 23 | |
|
| 1394 | 24 | | public RecordMatcher(bool compareBodies = true) |
| | 25 | | { |
| 1394 | 26 | | _compareBodies = compareBodies; |
| 1394 | 27 | | } |
| | 28 | |
|
| 1394 | 29 | | public HashSet<string> ExcludeHeaders = new HashSet<string>(StringComparer.OrdinalIgnoreCase) |
| 1394 | 30 | | { |
| 1394 | 31 | | "Date", |
| 1394 | 32 | | "x-ms-date", |
| 1394 | 33 | | "x-ms-client-request-id", |
| 1394 | 34 | | "User-Agent", |
| 1394 | 35 | | "Request-Id", |
| 1394 | 36 | | "traceparent" |
| 1394 | 37 | | }; |
| | 38 | |
|
| | 39 | | // Headers that don't indicate meaningful changes between updated recordings |
| 1394 | 40 | | public HashSet<string> VolatileHeaders = new HashSet<string>(StringComparer.OrdinalIgnoreCase) |
| 1394 | 41 | | { |
| 1394 | 42 | | "Date", |
| 1394 | 43 | | "x-ms-date", |
| 1394 | 44 | | "x-ms-client-request-id", |
| 1394 | 45 | | "User-Agent", |
| 1394 | 46 | | "Request-Id", |
| 1394 | 47 | | "If-Match", |
| 1394 | 48 | | "If-None-Match", |
| 1394 | 49 | | "If-Modified-Since", |
| 1394 | 50 | | "If-Unmodified-Since" |
| 1394 | 51 | | }; |
| | 52 | |
|
| | 53 | | // Headers that don't indicate meaningful changes between updated recordings |
| 1394 | 54 | | public HashSet<string> VolatileResponseHeaders = new HashSet<string>(StringComparer.OrdinalIgnoreCase) |
| 1394 | 55 | | { |
| 1394 | 56 | | "Date", |
| 1394 | 57 | | "ETag", |
| 1394 | 58 | | "Last-Modified", |
| 1394 | 59 | | "x-ms-request-id", |
| 1394 | 60 | | "x-ms-correlation-request-id" |
| 1394 | 61 | | }; |
| | 62 | |
|
| | 63 | | /// <summary> |
| | 64 | | /// Query parameters whose values can change between recording and playback without causing URI matching |
| | 65 | | /// to fail. The presence or absence of the query parameter itself is still respected in matching. |
| | 66 | | /// </summary> |
| 1394 | 67 | | public HashSet<string> VolatileQueryParameters = new HashSet<string>(StringComparer.OrdinalIgnoreCase) |
| 1394 | 68 | | { |
| 1394 | 69 | | }; |
| | 70 | |
|
| | 71 | | private const string VolatileValue = "Volatile"; |
| | 72 | |
|
| | 73 | | public virtual RecordEntry FindMatch(RecordEntry request, IList<RecordEntry> entries) |
| | 74 | | { |
| 326408 | 75 | | int bestScore = int.MaxValue; |
| 326408 | 76 | | RecordEntry bestScoreEntry = null; |
| | 77 | |
|
| 984184 | 78 | | foreach (RecordEntry entry in entries) |
| | 79 | | { |
| 328854 | 80 | | int score = 0; |
| | 81 | |
|
| 328854 | 82 | | var uri = request.RequestUri; |
| 328854 | 83 | | var recordRequestUri = entry.RequestUri; |
| 328854 | 84 | | if (entry.IsTrack1Recording) |
| | 85 | | { |
| | 86 | | //there's no domain name for request uri in track 1 record, so add it from reqeust uri |
| 0 | 87 | | int len = 8; //length of "https://" |
| 0 | 88 | | int domainEndingIndex = uri.IndexOf('/', len); |
| 0 | 89 | | if (domainEndingIndex > 0) |
| | 90 | | { |
| 0 | 91 | | recordRequestUri = uri.Substring(0, domainEndingIndex) + recordRequestUri; |
| | 92 | | } |
| | 93 | | } |
| | 94 | |
|
| 328854 | 95 | | if (!AreUrisSame(recordRequestUri, uri)) |
| | 96 | | { |
| 2460 | 97 | | score++; |
| | 98 | | } |
| | 99 | |
|
| 328854 | 100 | | if (entry.RequestMethod != request.RequestMethod) |
| | 101 | | { |
| 1434 | 102 | | score++; |
| | 103 | | } |
| | 104 | |
|
| | 105 | | //we only check Uri + RequestMethod for track1 record |
| 328854 | 106 | | if (!entry.IsTrack1Recording) |
| | 107 | | { |
| 328854 | 108 | | score += CompareHeaderDictionaries(request.Request.Headers, entry.Request.Headers, ExcludeHeaders); |
| 328854 | 109 | | score += CompareBodies(request.Request.Body, entry.Request.Body); |
| | 110 | | } |
| | 111 | |
|
| 328854 | 112 | | if (score == 0) |
| | 113 | | { |
| 326340 | 114 | | return entry; |
| | 115 | | } |
| | 116 | |
|
| 2514 | 117 | | if (score < bestScore) |
| | 118 | | { |
| 774 | 119 | | bestScoreEntry = entry; |
| 774 | 120 | | bestScore = score; |
| | 121 | | } |
| | 122 | | } |
| | 123 | |
|
| 68 | 124 | | throw new InvalidOperationException(GenerateException(request, bestScoreEntry)); |
| 326340 | 125 | | } |
| | 126 | |
|
| | 127 | | private int CompareBodies(byte[] requestBody, byte[] responseBody, StringBuilder descriptionBuilder = null) |
| | 128 | | { |
| 328918 | 129 | | if (!_compareBodies) |
| | 130 | | { |
| 43726 | 131 | | return 0; |
| | 132 | | } |
| | 133 | |
|
| 285192 | 134 | | if (requestBody == null && responseBody == null) |
| | 135 | | { |
| 274934 | 136 | | return 0; |
| | 137 | | } |
| | 138 | |
|
| 10258 | 139 | | if (requestBody == null) |
| | 140 | | { |
| 4 | 141 | | descriptionBuilder?.AppendLine("Request has body but response doesn't"); |
| 4 | 142 | | return 1; |
| | 143 | | } |
| | 144 | |
|
| 10254 | 145 | | if (responseBody == null) |
| | 146 | | { |
| 622 | 147 | | descriptionBuilder?.AppendLine("Response has body but request doesn't"); |
| 622 | 148 | | return 1; |
| | 149 | | } |
| | 150 | |
|
| 9632 | 151 | | if (!requestBody.SequenceEqual(responseBody)) |
| | 152 | | { |
| 12 | 153 | | if (descriptionBuilder != null) |
| | 154 | | { |
| 6 | 155 | | var minLength = Math.Min(requestBody.Length, responseBody.Length); |
| | 156 | | int i; |
| 5592 | 157 | | for (i = 0; i < minLength - 1; i++) |
| | 158 | | { |
| 2794 | 159 | | if (requestBody[i] != responseBody[i]) |
| | 160 | | { |
| | 161 | | break; |
| | 162 | | } |
| | 163 | | } |
| 6 | 164 | | descriptionBuilder.AppendLine($"Request and response bodies do not match at index {i}:"); |
| 6 | 165 | | var before = Math.Max(0, i - 10); |
| 6 | 166 | | var afterRequest = Math.Min(i + 20, requestBody.Length); |
| 6 | 167 | | var afterResponse = Math.Min(i + 20, responseBody.Length); |
| 6 | 168 | | descriptionBuilder.AppendLine($" request: \"{Encoding.UTF8.GetString(requestBody, before, afterR |
| 6 | 169 | | descriptionBuilder.AppendLine($" record: \"{Encoding.UTF8.GetString(responseBody, before, after |
| | 170 | | } |
| 12 | 171 | | return 1; |
| | 172 | | } |
| | 173 | |
|
| 9620 | 174 | | return 0; |
| | 175 | | } |
| | 176 | |
|
| | 177 | | public virtual bool IsEquivalentRecord(RecordEntry entry, RecordEntry otherEntry) => |
| 0 | 178 | | IsEquivalentRequest(entry, otherEntry) && |
| 0 | 179 | | IsEquivalentResponse(entry, otherEntry); |
| | 180 | |
|
| | 181 | | protected virtual bool IsEquivalentRequest(RecordEntry entry, RecordEntry otherEntry) => |
| 0 | 182 | | entry.RequestMethod == otherEntry.RequestMethod && |
| 0 | 183 | | IsEquivalentUri(entry.RequestUri, otherEntry.RequestUri) && |
| 0 | 184 | | CompareHeaderDictionaries(entry.Request.Headers, otherEntry.Request.Headers, VolatileHeaders) == 0; |
| | 185 | |
|
| | 186 | | private bool AreUrisSame(string entryUri, string otherEntryUri) => |
| 328918 | 187 | | NormalizeUri(entryUri) == NormalizeUri(otherEntryUri); |
| | 188 | |
|
| | 189 | | private string NormalizeUri(string uriToNormalize) |
| | 190 | | { |
| 657836 | 191 | | var req = new RequestUriBuilder(); |
| 657836 | 192 | | var uri = new Uri(uriToNormalize); |
| 657836 | 193 | | req.Reset(uri); |
| 657836 | 194 | | req.Query = ""; |
| 657836 | 195 | | NameValueCollection queryParams = HttpUtility.ParseQueryString(uri.Query); |
| 2701140 | 196 | | foreach (string param in queryParams) |
| | 197 | | { |
| 693070 | 198 | | req.AppendQuery( |
| 693070 | 199 | | param, |
| 693070 | 200 | | VolatileQueryParameters.Contains(param) ? VolatileValue : queryParams[param]); |
| | 201 | | } |
| 657836 | 202 | | return req.ToUri().ToString(); |
| | 203 | | } |
| | 204 | |
|
| | 205 | | protected virtual bool IsEquivalentUri(string entryUri, string otherEntryUri) => |
| 0 | 206 | | AreUrisSame(entryUri, otherEntryUri); |
| | 207 | |
|
| | 208 | | protected virtual bool IsEquivalentResponse(RecordEntry entry, RecordEntry otherEntry) |
| | 209 | | { |
| 0 | 210 | | IEnumerable<KeyValuePair<string, string[]>> entryHeaders = entry.Response.Headers.Where(h => !VolatileRespon |
| 0 | 211 | | IEnumerable<KeyValuePair<string, string[]>> otherEntryHeaders = otherEntry.Response.Headers.Where(h => !Vola |
| | 212 | |
|
| 0 | 213 | | return |
| 0 | 214 | | entry.StatusCode == otherEntry.StatusCode && |
| 0 | 215 | | entryHeaders.SequenceEqual(otherEntryHeaders, new HeaderComparer()) && |
| 0 | 216 | | IsBodyEquivalent(entry, otherEntry); |
| | 217 | | } |
| | 218 | |
|
| | 219 | | protected virtual bool IsBodyEquivalent(RecordEntry record, RecordEntry otherRecord) |
| | 220 | | { |
| 0 | 221 | | return (record.Response.Body ?? Array.Empty<byte>()).AsSpan() |
| 0 | 222 | | .SequenceEqual((otherRecord.Response.Body ?? Array.Empty<byte>())); |
| | 223 | | } |
| | 224 | |
|
| | 225 | | private string GenerateException(RecordEntry request, RecordEntry bestScoreEntry) |
| | 226 | | { |
| 68 | 227 | | StringBuilder builder = new StringBuilder(); |
| 68 | 228 | | builder.AppendLine($"Unable to find a record for the request {request.RequestMethod} {request.RequestUri}"); |
| | 229 | |
|
| 68 | 230 | | if (bestScoreEntry == null) |
| | 231 | | { |
| 4 | 232 | | builder.AppendLine("No records to match."); |
| 4 | 233 | | return builder.ToString(); |
| | 234 | | } |
| | 235 | |
|
| 64 | 236 | | if (request.RequestMethod != bestScoreEntry.RequestMethod) |
| | 237 | | { |
| 2 | 238 | | builder.AppendLine($"Method doesn't match, request <{request.RequestMethod}> record <{bestScoreEntry.Req |
| | 239 | | } |
| | 240 | |
|
| 64 | 241 | | if (!AreUrisSame(request.RequestUri, bestScoreEntry.RequestUri)) |
| | 242 | | { |
| 16 | 243 | | builder.AppendLine("Uri doesn't match:"); |
| 16 | 244 | | builder.AppendLine($" request <{request.RequestUri}>"); |
| 16 | 245 | | builder.AppendLine($" record <{bestScoreEntry.RequestUri}>"); |
| | 246 | | } |
| | 247 | |
|
| 64 | 248 | | builder.AppendLine("Header differences:"); |
| | 249 | |
|
| 64 | 250 | | CompareHeaderDictionaries(request.Request.Headers, bestScoreEntry.Request.Headers, ExcludeHeaders, builder); |
| | 251 | |
|
| 64 | 252 | | builder.AppendLine("Body differences:"); |
| | 253 | |
|
| 64 | 254 | | CompareBodies(request.Request.Body, bestScoreEntry.Request.Body, builder); |
| | 255 | |
|
| 64 | 256 | | return builder.ToString(); |
| | 257 | | } |
| | 258 | |
|
| | 259 | | private string JoinHeaderValues(string[] values) |
| | 260 | | { |
| 68 | 261 | | return string.Join(",", values); |
| | 262 | | } |
| | 263 | |
|
| | 264 | | private string[] RenormalizeSemicolons(string[] values) |
| | 265 | | { |
| 46036 | 266 | | string[] outputs = new string[values.Length]; |
| 184144 | 267 | | for (int i = 0; i < values.Length; i++) |
| | 268 | | { |
| 98048 | 269 | | outputs[i] = string.Join("; ", values[i].Split(';').Select(part => part.Trim())); |
| | 270 | | } |
| | 271 | |
|
| 46036 | 272 | | return outputs; |
| | 273 | | } |
| | 274 | |
|
| | 275 | | private int CompareHeaderDictionaries(SortedDictionary<string, string[]> headers, SortedDictionary<string, strin |
| | 276 | | { |
| 328918 | 277 | | int difference = 0; |
| 328918 | 278 | | var remaining = new SortedDictionary<string, string[]>(entryHeaders, entryHeaders.Comparer); |
| 3794100 | 279 | | foreach (KeyValuePair<string, string[]> header in headers) |
| | 280 | | { |
| 1568132 | 281 | | var requestHeaderValues = header.Value; |
| 1568132 | 282 | | var headerName = header.Key; |
| | 283 | |
|
| 1568132 | 284 | | if (ignoredHeaders.Contains(headerName)) |
| | 285 | | { |
| | 286 | | continue; |
| | 287 | | } |
| | 288 | |
|
| 793354 | 289 | | if (remaining.TryGetValue(headerName, out string[] entryHeaderValues)) |
| | 290 | | { |
| | 291 | | // Content-Type, Accept headers are normalized by HttpClient, re-normalize them before comparing |
| 791122 | 292 | | if (_normalizedHeaders.Contains(headerName)) |
| | 293 | | { |
| 23018 | 294 | | requestHeaderValues = RenormalizeSemicolons(requestHeaderValues); |
| 23018 | 295 | | entryHeaderValues = RenormalizeSemicolons(entryHeaderValues); |
| | 296 | | } |
| | 297 | |
|
| 791122 | 298 | | remaining.Remove(headerName); |
| 791122 | 299 | | if (!entryHeaderValues.SequenceEqual(requestHeaderValues)) |
| | 300 | | { |
| 34 | 301 | | difference++; |
| 34 | 302 | | descriptionBuilder?.AppendLine($" <{headerName}> values differ, request <{JoinHeaderValues(re |
| | 303 | | } |
| | 304 | | } |
| | 305 | | else |
| | 306 | | { |
| 2232 | 307 | | difference++; |
| 2232 | 308 | | descriptionBuilder?.AppendLine($" <{headerName}> is absent in record, value <{JoinHeaderValues(re |
| | 309 | | } |
| | 310 | | } |
| | 311 | |
|
| 2208216 | 312 | | foreach (KeyValuePair<string, string[]> header in remaining) |
| | 313 | | { |
| 775190 | 314 | | if (!ignoredHeaders.Contains(header.Key)) |
| | 315 | | { |
| 1512 | 316 | | difference++; |
| 1512 | 317 | | descriptionBuilder?.AppendLine($" <{header.Key}> is absent in request, value <{JoinHeaderValues(h |
| | 318 | | } |
| | 319 | | } |
| | 320 | |
|
| 328918 | 321 | | return difference; |
| | 322 | | } |
| | 323 | |
|
| | 324 | | private class HeaderComparer : IEqualityComparer<KeyValuePair<string, string[]>> |
| | 325 | | { |
| | 326 | | public bool Equals(KeyValuePair<string, string[]> x, KeyValuePair<string, string[]> y) |
| | 327 | | { |
| 0 | 328 | | return x.Key.Equals(y.Key, StringComparison.OrdinalIgnoreCase) && |
| 0 | 329 | | x.Value.SequenceEqual(y.Value); |
| | 330 | | } |
| | 331 | |
|
| | 332 | | public int GetHashCode(KeyValuePair<string, string[]> obj) |
| | 333 | | { |
| 0 | 334 | | return obj.GetHashCode(); |
| | 335 | | } |
| | 336 | | } |
| | 337 | | } |
| | 338 | | } |