From fd23ba44ad35748931fe9ad410645337e4b2b30d Mon Sep 17 00:00:00 2001 From: Yashvardhan Thanvi Date: Sun, 18 Oct 2026 17:30:57 +0530 Subject: [PATCH] fix(event_handler): strip the HTTP API stage only when rawPath has it APIGatewayProxyEventV2.path removed len("/" + stage) characters from rawPath for any named stage. Behind a custom domain API mapping or in SAM local the stage is not part of rawPath, so the path was truncated and every route returned 404. Strip the stage only when rawPath equals "/{stage}" or starts with "/{stage}/", and return "/" for the stage root. --- .../data_classes/api_gateway_proxy_event.py | 18 ++++++++-- .../required_dependencies/test_api_gateway.py | 35 +++++++++++++++++++ .../test_api_gateway_proxy_event.py | 25 +++++++++++++ 3 files changed, 75 insertions(+), 3 deletions(-) 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)