Skip to content

Commit 774c4e6

Browse files
fix(mock): disambiguate template-path siblings by body bindings
When a recording captures the same verb+template (e.g. three calls to GET /pet/{petId} with petId = 3, 5, 10) the matcher now scores each hit by how many request-path bindings line up with the recorded request body and returns the best match — so the mock answers /pet/5 with the response captured for petId=5 instead of always returning the first template hit. Literal-path matches still short-circuit and ties keep the historical first-match-wins order, so single-step recordings and legacy captures without body data are unchanged.
1 parent 0f36805 commit 774c4e6

2 files changed

Lines changed: 221 additions & 2 deletions

File tree

src/Kuestenlogik.Bowire.Mock/Matchers/ExactMatcher.cs

Lines changed: 120 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// SPDX-License-Identifier: Apache-2.0
33

44
using System.Collections.Concurrent;
5+
using System.Text.Json;
56
using System.Text.RegularExpressions;
67
using Kuestenlogik.Bowire.Mocking;
78

@@ -24,7 +25,14 @@ namespace Kuestenlogik.Bowire.Mock.Matchers;
2425
/// <c>/{service}/{method}</c> URL path.
2526
/// </item>
2627
/// </list>
27-
/// Non-unary steps are always skipped; the first matching step wins.
28+
/// Non-unary steps are always skipped. When several recorded steps share
29+
/// the same verb + path template (e.g. three captures of
30+
/// <c>GET /pet/{petId}</c> with <c>petId</c> = 3, 5, 10), the matcher
31+
/// scores each candidate by how well its recorded request body matches the
32+
/// incoming path-bindings and picks the best hit — so a mock call against
33+
/// <c>/pet/5</c> returns the response for <c>petId = 5</c> instead of
34+
/// always handing back the first capture. Ties (and the historical
35+
/// single-template path) keep the original "first match wins" order.
2836
/// Phase 2 later adds a topic matcher for MQTT / Socket.IO wildcards as a
2937
/// separate <see cref="IMockMatcher"/> implementation.
3038
/// </summary>
@@ -35,13 +43,24 @@ public sealed class ExactMatcher : IMockMatcher
3543
// is shared across every incoming request.
3644
private static readonly ConcurrentDictionary<string, Regex> s_templateCache = new(StringComparer.Ordinal);
3745

46+
// Score handed back for a non-template match (literal path equality or
47+
// a gRPC/Socket.IO candidate where the matcher has no further axis to
48+
// rank on). Higher than any plausible body-binding count so a literal
49+
// hit short-circuits before the matcher inspects the rest of the list,
50+
// preserving the original "first literal match wins" contract.
51+
private const int LiteralOrNonRestMatchScore = 1_000_000;
52+
3853
public bool TryMatch(MockRequest request, BowireRecording recording, out BowireRecordingStep matchedStep)
3954
{
4055
ArgumentNullException.ThrowIfNull(request);
4156
ArgumentNullException.ThrowIfNull(recording);
4257

58+
BowireRecordingStep? bestStep = null;
59+
var bestScore = -1;
60+
4361
foreach (var candidate in recording.Steps)
4462
{
63+
int score;
4564
// Skip protocol-shaped steps that don't match the request family.
4665
// Replay-ability (unary vs streaming, sent-messages for duplex) is
4766
// the replayer's concern — here we only pair incoming wire shape
@@ -50,6 +69,7 @@ public bool TryMatch(MockRequest request, BowireRecording recording, out BowireR
5069
{
5170
if (!IsGrpcStep(candidate)) continue;
5271
if (!MatchesGrpcPath(candidate, request)) continue;
72+
score = LiteralOrNonRestMatchScore;
5373
}
5474
else if (IsSocketIoStep(candidate))
5575
{
@@ -59,21 +79,119 @@ public bool TryMatch(MockRequest request, BowireRecording recording, out BowireR
5979
// instead: any GET upgrade on /socket.io/* pairs
6080
// against the first socketio step in the recording.
6181
if (!MatchesSocketIoRequest(request)) continue;
82+
score = LiteralOrNonRestMatchScore;
6283
}
6384
else
6485
{
6586
if (!IsRestStep(candidate)) continue;
6687
if (!MatchesRestVerbAndPath(candidate, request)) continue;
88+
score = ScoreRestCandidate(candidate, request);
89+
}
90+
91+
if (score > bestScore)
92+
{
93+
bestStep = candidate;
94+
bestScore = score;
6795
}
6896

69-
matchedStep = candidate;
97+
// Literal / non-REST matches don't get any extra signal from
98+
// body inspection, so the first one always wins — short-circuit
99+
// to preserve the historical order-of-capture tie-break.
100+
if (bestScore >= LiteralOrNonRestMatchScore) break;
101+
}
102+
103+
if (bestStep is not null)
104+
{
105+
matchedStep = bestStep;
70106
return true;
71107
}
72108

73109
matchedStep = null!;
74110
return false;
75111
}
76112

113+
/// <summary>
114+
/// Rank a REST candidate that already passed verb + path-or-template
115+
/// matching. Literal-path hits always outrank template hits (no body
116+
/// inspection needed). For template hits, the score counts how many of
117+
/// the captured path bindings (e.g. <c>petId = 5</c>) line up with the
118+
/// recorded request body — a step recorded against <c>petId = 5</c>
119+
/// outranks a sibling recorded against <c>petId = 3</c> when the
120+
/// incoming request asks for pet 5. Missing / unparseable bodies score
121+
/// zero and the historical "first match wins" tie-break takes over.
122+
/// </summary>
123+
private static int ScoreRestCandidate(BowireRecordingStep step, MockRequest request)
124+
{
125+
var template = step.HttpPath!;
126+
if (!IsTemplate(template))
127+
{
128+
// Literal-path equality already filtered out non-matches in
129+
// MatchesRestVerbAndPath; everyone here is an exact hit.
130+
return LiteralOrNonRestMatchScore;
131+
}
132+
133+
var bindings = ExtractTemplateBindings(template, request.Path);
134+
if (bindings is null || bindings.Count == 0) return 0;
135+
if (string.IsNullOrWhiteSpace(step.Body)) return 0;
136+
137+
Dictionary<string, string>? bodyValues;
138+
try
139+
{
140+
bodyValues = ParseBodyStringValues(step.Body!);
141+
}
142+
catch (JsonException)
143+
{
144+
return 0;
145+
}
146+
147+
if (bodyValues is null || bodyValues.Count == 0) return 0;
148+
149+
var matched = 0;
150+
foreach (var (name, value) in bindings)
151+
{
152+
if (bodyValues.TryGetValue(name, out var recorded)
153+
&& string.Equals(recorded, value, StringComparison.Ordinal))
154+
{
155+
matched++;
156+
}
157+
}
158+
return matched;
159+
}
160+
161+
/// <summary>
162+
/// Flatten a step body into a name → string-form map. The recorder
163+
/// keeps the captured input as the JSON the user submitted (e.g.
164+
/// <c>{"petId":3}</c>); we coerce primitive values to their canonical
165+
/// text form so the matcher can compare them against URL-decoded path
166+
/// segments without caring whether the user typed a number or a string.
167+
/// </summary>
168+
private static Dictionary<string, string>? ParseBodyStringValues(string bodyJson)
169+
{
170+
using var doc = JsonDocument.Parse(bodyJson);
171+
if (doc.RootElement.ValueKind != JsonValueKind.Object) return null;
172+
173+
var map = new Dictionary<string, string>(StringComparer.Ordinal);
174+
foreach (var prop in doc.RootElement.EnumerateObject())
175+
{
176+
var v = prop.Value;
177+
switch (v.ValueKind)
178+
{
179+
case JsonValueKind.String:
180+
map[prop.Name] = v.GetString() ?? string.Empty;
181+
break;
182+
case JsonValueKind.Number:
183+
case JsonValueKind.True:
184+
case JsonValueKind.False:
185+
map[prop.Name] = v.GetRawText();
186+
break;
187+
default:
188+
// Arrays / objects don't appear in path bindings — skip.
189+
break;
190+
}
191+
}
192+
return map;
193+
}
194+
77195
private static bool IsRestStep(BowireRecordingStep s) =>
78196
!string.IsNullOrEmpty(s.HttpPath) &&
79197
!string.IsNullOrEmpty(s.HttpVerb);

tests/Kuestenlogik.Bowire.Mock.Tests/ExactMatcherTests.cs

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,4 +259,105 @@ public void TryMatch_LiteralPath_StillMatchesExactly()
259259
Assert.False(matcher.TryMatch(Req("GET", "/WEATHER"), rec, out _)); // path case-sensitive
260260
Assert.False(matcher.TryMatch(Req("GET", "/weather/extra"), rec, out _));
261261
}
262+
263+
// ---- Body-bound disambiguation across siblings sharing a template ----
264+
265+
private static BowireRecordingStep RestStepWithBody(
266+
string verb, string path, string body, string response) => new()
267+
{
268+
Id = "step_" + Guid.NewGuid().ToString("N")[..8],
269+
Protocol = "rest",
270+
Service = "PetService",
271+
Method = "GetPetById",
272+
MethodType = "Unary",
273+
HttpPath = path,
274+
HttpVerb = verb,
275+
Body = body,
276+
Status = "OK",
277+
Response = response
278+
};
279+
280+
[Fact]
281+
public void TryMatch_OnTemplatePath_PrefersStepWhoseBodyBindingMatchesRequestPath()
282+
{
283+
// Three captures of GET /pet/{petId} with petId = 3, 5, 10. A mock
284+
// call against /pet/5 must return the response captured for petId=5,
285+
// not the first matching template hit.
286+
var matcher = new ExactMatcher();
287+
var rec = MakeRecording(
288+
RestStepWithBody("GET", "/pet/{petId}", """{"petId":3}""", """{"id":3}"""),
289+
RestStepWithBody("GET", "/pet/{petId}", """{"petId":5}""", """{"id":5}"""),
290+
RestStepWithBody("GET", "/pet/{petId}", """{"petId":10}""", """{"id":10}"""));
291+
292+
Assert.True(matcher.TryMatch(Req("GET", "/pet/5"), rec, out var step));
293+
Assert.Equal("""{"id":5}""", step.Response);
294+
}
295+
296+
[Fact]
297+
public void TryMatch_OnTemplatePath_BodyBindingHandlesStringValues()
298+
{
299+
// Path bindings arrive as URL segments (strings); the recorded body
300+
// may carry the value as a string literal. Compare on the canonical
301+
// text form so quoted-string captures still match.
302+
var matcher = new ExactMatcher();
303+
var rec = MakeRecording(
304+
RestStepWithBody("GET", "/users/{id}", """{"id":"alice"}""", """{"name":"A"}"""),
305+
RestStepWithBody("GET", "/users/{id}", """{"id":"bob"}""", """{"name":"B"}"""));
306+
307+
Assert.True(matcher.TryMatch(Req("GET", "/users/bob"), rec, out var step));
308+
Assert.Equal("""{"name":"B"}""", step.Response);
309+
}
310+
311+
[Fact]
312+
public void TryMatch_OnTemplatePath_NoBodyBindingMatch_FallsBackToFirstHit()
313+
{
314+
// None of the recorded steps carry a body field that maps to the
315+
// request's path binding. The matcher has no signal to pick between
316+
// them — the historical "first match in capture order wins" tie-break
317+
// takes over so legacy recordings keep working.
318+
var matcher = new ExactMatcher();
319+
var rec = MakeRecording(
320+
RestStepWithBody("GET", "/pet/{petId}", "{}", """{"id":1}"""),
321+
RestStepWithBody("GET", "/pet/{petId}", "{}", """{"id":2}"""));
322+
323+
Assert.True(matcher.TryMatch(Req("GET", "/pet/42"), rec, out var step));
324+
Assert.Equal("""{"id":1}""", step.Response);
325+
}
326+
327+
[Fact]
328+
public void TryMatch_OnTemplatePath_MalformedBody_FallsBackToFirstHit()
329+
{
330+
// A recording whose body isn't parseable JSON (e.g. legacy free-text
331+
// capture) shouldn't blow up the matcher — it just scores zero and
332+
// the first template hit still wins.
333+
var matcher = new ExactMatcher();
334+
var rec = MakeRecording(
335+
RestStepWithBody("GET", "/pet/{petId}", "not-json", """{"id":1}"""),
336+
RestStepWithBody("GET", "/pet/{petId}", """{"petId":7}""", """{"id":7}"""));
337+
338+
// Request for pet 7 still finds the right body-bound step.
339+
Assert.True(matcher.TryMatch(Req("GET", "/pet/7"), rec, out var step7));
340+
Assert.Equal("""{"id":7}""", step7.Response);
341+
342+
// Request for an id that nobody recorded falls back to the first
343+
// template hit (the malformed-body one).
344+
Assert.True(matcher.TryMatch(Req("GET", "/pet/99"), rec, out var stepFallback));
345+
Assert.Equal("""{"id":1}""", stepFallback.Response);
346+
}
347+
348+
[Fact]
349+
public void TryMatch_OnTemplatePath_MultipleBindings_PrefersBestBodyOverlap()
350+
{
351+
// Two-parameter template /users/{uid}/posts/{pid}: the step that
352+
// matches BOTH bindings outranks the one that matches only one.
353+
var matcher = new ExactMatcher();
354+
var rec = MakeRecording(
355+
RestStepWithBody("GET", "/users/{uid}/posts/{pid}",
356+
"""{"uid":42,"pid":1}""", """{"post":"first"}"""),
357+
RestStepWithBody("GET", "/users/{uid}/posts/{pid}",
358+
"""{"uid":42,"pid":7}""", """{"post":"seventh"}"""));
359+
360+
Assert.True(matcher.TryMatch(Req("GET", "/users/42/posts/7"), rec, out var step));
361+
Assert.Equal("""{"post":"seventh"}""", step.Response);
362+
}
262363
}

0 commit comments

Comments
 (0)