From 3a84785d810a90e0ce3af4dd1a9f5ab8576a6d03 Mon Sep 17 00:00:00 2001 From: Guillaume Marty <279286709+LemonCondor@users.noreply.github.com> Date: Tue, 13 Oct 2026 11:13:20 +0200 Subject: [PATCH] fix(event_handler): match AppSync Events wildcard routes on segment boundaries find_best_route() turned a trailing "/*" into ".*" by slicing six characters off "[^/]+", which also removed the preceding "/". A route such as "/default/users/*" was compiled to ^/default/users.*$ and matched "/default/usersettings", so that channel was dispatched to the more specific but wrong handler instead of "/default/*". Strip only the wildcard pattern so the separator is kept. --- .../event_handler/events_appsync/functions.py | 8 +++-- .../appsync/test_appsync_events_resolvers.py | 31 +++++++++++++++++++ .../appsync_events/test_functions.py | 28 +++++++++++++++++ 3 files changed, 64 insertions(+), 3 deletions(-) diff --git a/aws_lambda_powertools/event_handler/events_appsync/functions.py b/aws_lambda_powertools/event_handler/events_appsync/functions.py index 0d7ddf2518f..36e1bce0f41 100644 --- a/aws_lambda_powertools/event_handler/events_appsync/functions.py +++ b/aws_lambda_powertools/event_handler/events_appsync/functions.py @@ -71,7 +71,7 @@ def pattern_to_regex(route): """ Convert a route pattern to a regex pattern with caching. Examples: - /default/v1/* -> ^/default/v1/[^/]+$ + /default/v1/* -> ^/default/v1/.*$ /default/v1/users/* -> ^/default/v1/users/.*$ Parameters @@ -87,9 +87,11 @@ def pattern_to_regex(route): # Escape special regex chars but convert * to regex pattern pattern = re.escape(route).replace("\\*", "[^/]+") - # If pattern ends with [^/]+, replace with .* for multi-segment match + # If pattern ends with [^/]+, replace with .* for multi-segment match. + # Only the wildcard is replaced: the preceding "/" must be kept so that + # "/default/users/*" does not match "/default/usersettings". if pattern.endswith("[^/]+"): - pattern = pattern[:-6] + ".*" + pattern = pattern[: -len("[^/]+")] + ".*" # Compile and return the regex pattern return re.compile(f"^{pattern}$") diff --git a/tests/functional/event_handler/required_dependencies/appsync/test_appsync_events_resolvers.py b/tests/functional/event_handler/required_dependencies/appsync/test_appsync_events_resolvers.py index 4d53c3cb934..a38440377de 100644 --- a/tests/functional/event_handler/required_dependencies/appsync/test_appsync_events_resolvers.py +++ b/tests/functional/event_handler/required_dependencies/appsync/test_appsync_events_resolvers.py @@ -1024,6 +1024,37 @@ def phones_handler(payload): assert result == expected_result +def test_wildcard_route_does_not_match_sibling_segment_with_same_prefix(lambda_context, mock_event): + """Test that a wildcard route only matches whole path segments.""" + # GIVEN a publish event on a channel that only shares a string prefix with a more specific route + mock_event["info"]["channel"]["path"] = "/default/usersettings" + mock_event["events"] = [ + {"id": "123", "payload": {"data": "settings data"}}, + ] + + # GIVEN an AppSyncEventsResolver with a generic and a more specific route + app = AppSyncEventsResolver() + + @app.on_publish(path="/default/*") + def default_handler(payload): + return {"handler": "default", "data": payload["data"]} + + @app.on_publish(path="/default/users/*") + def users_handler(payload): + return {"handler": "users", "data": payload["data"]} + + # WHEN we resolve the event + result = app.resolve(mock_event, lambda_context) + + # THEN the generic route should be used, since /default/usersettings is not under /default/users/ + expected_result = { + "events": [ + {"id": "123", "payload": {"handler": "default", "data": "settings data"}}, + ], + } + assert result == expected_result + + def test_integration_with_external_service(lambda_context, mock_event): """Test integration with an external service using mocks.""" # GIVEN a sample publish event diff --git a/tests/unit/event_handler/_required_dependencies/appsync_events/test_functions.py b/tests/unit/event_handler/_required_dependencies/appsync_events/test_functions.py index e69de29bb2d..45a223e2641 100644 --- a/tests/unit/event_handler/_required_dependencies/appsync_events/test_functions.py +++ b/tests/unit/event_handler/_required_dependencies/appsync_events/test_functions.py @@ -0,0 +1,28 @@ +import pytest + +from aws_lambda_powertools.event_handler.events_appsync.functions import find_best_route + + +@pytest.mark.parametrize( + "path, expected_route", + [ + ("/default/users/123", "/default/users/*"), + ("/default/users/123/profile", "/default/users/*"), + ("/default/usersettings", "/default/*"), + ("/default/users-archive/1", "/default/*"), + ("/other/channel", "/*"), + ], +) +def test_find_best_route_wildcard_matches_whole_segments(path, expected_route): + # GIVEN routes where a more specific route shares a string prefix with sibling channels + routes = { + "/*": {"func": lambda x: x, "aggregate": False}, + "/default/*": {"func": lambda x: x, "aggregate": False}, + "/default/users/*": {"func": lambda x: x, "aggregate": False}, + } + + # WHEN finding the best route + result = find_best_route(routes, path) + + # THEN the wildcard only matches whole path segments + assert result == expected_route