2626import io .gravitee .definition .model .v4 .flow .Flow ;
2727import io .gravitee .definition .model .v4 .flow .selector .ChannelSelector ;
2828import io .gravitee .definition .model .v4 .flow .selector .HttpSelector ;
29- import io .gravitee .definition .model .v4 .flow .selector .McpSelector ;
3029import io .gravitee .definition .model .v4 .flow .selector .Selector ;
3130import io .gravitee .definition .model .v4 .flow .selector .SelectorType ;
3231import io .gravitee .definition .model .v4 .flow .step .Step ;
3332import io .gravitee .definition .model .v4 .nativeapi .NativeFlow ;
3433import java .util .ArrayList ;
34+ import java .util .Arrays ;
3535import java .util .Collection ;
3636import java .util .HashMap ;
3737import java .util .HashSet ;
4040import java .util .Objects ;
4141import java .util .Optional ;
4242import java .util .Set ;
43- import java .util .concurrent .atomic .AtomicBoolean ;
4443import java .util .function .Function ;
4544import java .util .regex .Pattern ;
4645import java .util .stream .Collectors ;
@@ -227,7 +226,7 @@ public void validatePathParameters(ApiType apiType, Stream<Flow> apiFlows, Strea
227226 planFlows = planFlows == null ? Stream .empty () : planFlows ;
228227 // group all flows in one stream
229228 final Stream <Flow > flowsWithPathParam = filterFlowsWithPathParam (apiType , apiFlows , planFlows );
230- validatePathParamOverlapping (apiType , flowsWithPathParam );
229+ checkOverlappingPaths (apiType , flowsWithPathParam );
231230 }
232231
233232 private Stream <Flow > filterFlowsWithPathParam (ApiType apiType , Stream <Flow > apiFlows , Stream <Flow > planFlows ) {
@@ -236,57 +235,120 @@ private Stream<Flow> filterFlowsWithPathParam(ApiType apiType, Stream<Flow> apiF
236235 .filter (flow -> containsPathParam (apiType , flow ));
237236 }
238237
239- private void validatePathParamOverlapping (ApiType apiType , Stream <Flow > flows ) {
240- Map <String , Integer > paramWithPosition = new HashMap <>();
241- Map <String , List <String >> pathsByParam = new HashMap <>();
242- final AtomicBoolean hasOverlap = new AtomicBoolean (false );
243-
244- flows .forEach (flow -> {
245- final String path = extractPath (apiType , flow );
246- String [] branches = SEPARATOR_SPLITTER .split (path );
247- for (int i = 0 ; i < branches .length ; i ++) {
248- final String currentBranch = branches [i ];
249- if (currentBranch .startsWith (PATH_PARAM_PREFIX )) {
250- // Store every path for a path param in a map
251- prepareOverlapsMap (pathsByParam , path , currentBranch );
252- if (isOverlapping (paramWithPosition , currentBranch , i )) {
253- // Exception is thrown later to be able to provide every overlapping case to the end user
254- hasOverlap .set (true );
255- } else {
256- paramWithPosition .put (currentBranch , i );
257- }
238+ private void checkOverlappingPaths (ApiType apiType , Stream <Flow > flows ) {
239+ // Extract unique, non-empty paths from enabled flows
240+ List <String > uniquePaths = flows
241+ .map (flow -> extractPath (apiType , flow ))
242+ .map (this ::normalizePath ) // normalize to avoid ambiguity due to slashes/case
243+ .filter (path -> !path .isEmpty ())
244+ .distinct ()
245+ .toList ();
246+
247+ Map <String , Set <String >> overlappingPaths = new HashMap <>();
248+ int pathCount = uniquePaths .size ();
249+
250+ for (int i = 0 ; i < pathCount ; i ++) {
251+ String path1 = uniquePaths .get (i );
252+ String [] segments1 = splitPathSegments (path1 );
253+
254+ for (int j = i + 1 ; j < pathCount ; j ++) {
255+ String path2 = uniquePaths .get (j );
256+ String [] segments2 = splitPathSegments (path2 );
257+
258+ if (segments1 .length != segments2 .length ) continue ;
259+
260+ if (arePathsAmbiguous (segments1 , segments2 )) {
261+ // Use a deterministic grouping key to avoid merging unrelated conflicts
262+ String key = buildAmbiguitySignature (segments1 );
263+ Set <String > paths = overlappingPaths .computeIfAbsent (key , k -> new HashSet <>());
264+ paths .add (path1 );
265+ paths .add (path2 );
258266 }
259267 }
260- });
268+ }
261269
262- if (hasOverlap .get ()) {
263- throw new ValidationDomainException (
264- "Some path parameters are used at different position across different flows." ,
265- pathsByParam
266- .entrySet ()
267- .stream ()
268- // Only keep params with overlap
269- .filter (entry -> entry .getValue ().size () > 1 )
270- .collect (Collectors .toMap (Map .Entry ::getKey , entry -> entry .getValue ().toString ()))
271- );
270+ if (!overlappingPaths .isEmpty ()) {
271+ // Sort lists for stable output
272+ Map <String , String > payload = overlappingPaths
273+ .entrySet ()
274+ .stream ()
275+ .collect (
276+ Collectors .toMap (Map .Entry ::getKey , entry -> {
277+ List <String > sortedPaths = new ArrayList <>(entry .getValue ());
278+ sortedPaths .sort (String ::compareTo );
279+ return sortedPaths .toString ();
280+ })
281+ );
282+
283+ throw new ValidationDomainException ("Invalid path parameters" , payload );
272284 }
273285 }
274286
275- private static void prepareOverlapsMap (Map <String , List <String >> pathsByParam , String path , String branches ) {
276- pathsByParam .compute (branches , (key , value ) -> {
277- if (value == null ) {
278- value = new ArrayList <>();
279- }
280- // Add the path only once to the error message
281- if (!value .contains (path )) {
282- value .add (path );
283- }
284- return value ;
285- });
287+ /**
288+ * Returns true if the two paths (split into segments) are ambiguous per OpenAPI 3.0:
289+ * - Same number of segments
290+ * - For each segment: both are parameters, or both are static and equal
291+ */
292+ private boolean arePathsAmbiguous (String [] segments1 , String [] segments2 ) {
293+ for (int i = 0 ; i < segments1 .length ; i ++) {
294+ boolean isParam1 = segments1 [i ].startsWith (PATH_PARAM_PREFIX );
295+ boolean isParam2 = segments2 [i ].startsWith (PATH_PARAM_PREFIX );
296+
297+ if (isParam1 && isParam2 ) continue ;
298+
299+ if (!isParam1 && !isParam2 && segments1 [i ].equals (segments2 [i ])) continue ;
300+
301+ return false ;
302+ }
303+
304+ return true ;
305+ }
306+
307+ /**
308+ * Normalize path:
309+ * - Collapse multiple slashes
310+ * - Remove trailing slash (except root "/")
311+ * - Lowercase literals if routing is case-insensitive; keeping case as-is here
312+ */
313+ private String normalizePath (String raw ) {
314+ if (raw == null ) return "" ;
315+ String p = raw .trim ();
316+
317+ if (p .isEmpty ()) return "" ;
318+ // Collapse multiple slashes
319+ p = p .replaceAll ("/{2,}" , PATH_SEPARATOR );
320+ // Remove trailing slash except root
321+ if (p .length () > 1 && p .endsWith (PATH_SEPARATOR )) {
322+ p = p .substring (0 , p .length () - 1 );
323+ }
324+ // Ensure leading slash for consistency
325+ if (!p .startsWith (PATH_SEPARATOR )) {
326+ p = PATH_SEPARATOR + p ;
327+ }
328+
329+ return p ;
330+ }
331+
332+ /**
333+ * Split path into non-empty segments after normalization.
334+ */
335+ private String [] splitPathSegments (String path ) {
336+ return Arrays .stream (SEPARATOR_SPLITTER .split (path ))
337+ .filter (s -> !s .isEmpty ())
338+ .toArray (String []::new );
286339 }
287340
288- private static boolean isOverlapping (Map <String , Integer > paramWithPosition , String param , Integer i ) {
289- return paramWithPosition .containsKey (param ) && !paramWithPosition .get (param ).equals (i );
341+ /**
342+ * Build a deterministic ambiguity signature by replacing any parameter segment with ":" and keeping literals.
343+ * Example: /users/:id/orders -> /users/:/orders
344+ */
345+ private String buildAmbiguitySignature (String [] segments ) {
346+ return (
347+ PATH_SEPARATOR +
348+ Arrays .stream (segments )
349+ .map (s -> s .startsWith (PATH_PARAM_PREFIX ) ? PATH_PARAM_PREFIX : s )
350+ .collect (Collectors .joining (PATH_SEPARATOR ))
351+ );
290352 }
291353
292354 private static Boolean containsPathParam (ApiType apiType , Flow flow ) {
0 commit comments