diff --git a/aws_lambda_powertools/utilities/data_classes/api_gateway_proxy_event.py b/aws_lambda_powertools/utilities/data_classes/api_gateway_proxy_event.py index 88f6cb8fa54..b92792d5d51 100644 --- a/aws_lambda_powertools/utilities/data_classes/api_gateway_proxy_event.py +++ b/aws_lambda_powertools/utilities/data_classes/api_gateway_proxy_event.py @@ -315,10 +315,22 @@ def stage_variables(self) -> dict[str, str]: @property def path(self) -> str: + """The request path, without the stage when `rawPath` starts with it. + + A named stage is part of `rawPath` when the request comes through the execute-api endpoint, + but not through a custom domain API mapping or SAM local. + """ + raw_path = self.raw_path stage = self.request_context.stage - if stage != "$default": - return self.raw_path[len("/" + stage) :] - return self.raw_path + if stage == "$default": + return raw_path + + stage_prefix = f"/{stage}" + if raw_path == stage_prefix: + return "/" + if raw_path.startswith(f"{stage_prefix}/"): + return raw_path[len(stage_prefix) :] + return raw_path @property def http_method(self) -> str: diff --git a/tests/functional/event_handler/required_dependencies/test_api_gateway.py b/tests/functional/event_handler/required_dependencies/test_api_gateway.py index 65d1a50082d..6a7f9d90dba 100644 --- a/tests/functional/event_handler/required_dependencies/test_api_gateway.py +++ b/tests/functional/event_handler/required_dependencies/test_api_gateway.py @@ -1378,6 +1378,41 @@ def foo(): assert result["headers"]["Content-Type"] == content_types.APPLICATION_JSON +@pytest.mark.parametrize( + "stage, raw_path, route, expected_path", + [ + pytest.param("dev", "/dev/my/path", "/my/path", "/my/path", id="named stage in raw path"), + pytest.param("dev", "/my/path", "/my/path", "/my/path", id="named stage not in raw path"), + pytest.param("$default", "/my/path", "/my/path", "/my/path", id="default stage"), + pytest.param("dev", "/dev", "/", "/", id="raw path equals the stage"), + pytest.param("dev", "/dev/", "/", "/", id="raw path equals the stage with trailing slash"), + pytest.param("dev", "/devices", "/devices", "/devices", id="raw path starts with the stage name"), + pytest.param("dev", "/dev/devices", "/devices", "/devices", id="named stage and path starting with its name"), + ], +) +def test_api_gateway_v2_stage_prefix_in_raw_path(stage, raw_path, route, expected_path): + # GIVEN an HTTP API resolver with a single route + app = APIGatewayHttpResolver() + + @app.get(route) + def handler(): + return {"path": app.current_event.path} + + # AND a request for that stage. The execute-api endpoint puts a named stage in rawPath, + # while custom domain API mappings and SAM local send rawPath without it + event = load_event("apiGatewayProxyV2Event.json") + event["requestContext"]["stage"] = stage + event["requestContext"]["http"]["method"] = "GET" + event["rawPath"] = event["requestContext"]["http"]["path"] = raw_path + + # WHEN calling the event handler + result = app(event, {}) + + # THEN the stage is stripped only when rawPath starts with it, and the route is found + assert result["statusCode"] == 200 + assert json.loads(result["body"]) == {"path": expected_path} + + def test_api_gateway_request_path_equals_strip_prefix(): # GIVEN a strip_prefix matches the request path app = ApiGatewayResolver(strip_prefixes=["/foo"]) diff --git a/tests/unit/data_classes/required_dependencies/test_api_gateway_proxy_event.py b/tests/unit/data_classes/required_dependencies/test_api_gateway_proxy_event.py index fd9ca1cca76..a7277799a6e 100644 --- a/tests/unit/data_classes/required_dependencies/test_api_gateway_proxy_event.py +++ b/tests/unit/data_classes/required_dependencies/test_api_gateway_proxy_event.py @@ -1,5 +1,7 @@ from __future__ import annotations +import pytest + from aws_lambda_powertools.utilities.data_classes import ( APIGatewayProxyEvent, APIGatewayProxyEventV2, @@ -200,6 +202,29 @@ def test_api_gateway_proxy_v2_event(): assert parsed_event.stage_variables == raw_event["stageVariables"] +@pytest.mark.parametrize( + "stage, raw_path, expected_path", + [ + pytest.param("$default", "/my/path", "/my/path", id="default stage"), + pytest.param("dev", "/dev/my/path", "/my/path", id="named stage in raw path"), + pytest.param("dev", "/my/path", "/my/path", id="named stage not in raw path"), + pytest.param("dev", "/dev", "/", id="raw path equals the stage"), + pytest.param("dev", "/dev/", "/", id="raw path equals the stage with trailing slash"), + pytest.param("dev", "/devices", "/devices", id="raw path starts with the stage name"), + pytest.param("dev", "/dev/dev", "/dev", id="stage is stripped once"), + ], +) +def test_api_gateway_proxy_v2_event_path_strips_stage_only_when_present(stage, raw_path, expected_path): + raw_event = load_event("apiGatewayProxyV2Event.json") + raw_event["requestContext"]["stage"] = stage + raw_event["rawPath"] = raw_path + + parsed_event = APIGatewayProxyEventV2(raw_event) + + assert parsed_event.path == expected_path + assert parsed_event.raw_path == raw_path + + def test_api_gateway_proxy_v2_lambda_authorizer_event(): raw_event = load_event("apiGatewayProxyV2LambdaAuthorizerEvent.json") parsed_event = APIGatewayProxyEventV2(raw_event)