22// SPDX-License-Identifier: Apache-2.0
33
44using System . Collections . Concurrent ;
5+ using System . Text . Json ;
56using System . Text . RegularExpressions ;
67using 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 ) ;
0 commit comments