From e697be2758803ba7686b518fd579ea3858fa4d3d Mon Sep 17 00:00:00 2001 From: Gavin MacFadyen Date: Mon, 19 Oct 2026 09:44:25 -0300 Subject: [PATCH 1/8] fix(event_handler): only strip a leading match for regex strip_prefixes --- .../event_handler/api_gateway.py | 5 +++- .../required_dependencies/test_api_gateway.py | 23 +++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/aws_lambda_powertools/event_handler/api_gateway.py b/aws_lambda_powertools/event_handler/api_gateway.py index 70b099269db..9ad3bc918a6 100644 --- a/aws_lambda_powertools/event_handler/api_gateway.py +++ b/aws_lambda_powertools/event_handler/api_gateway.py @@ -2880,7 +2880,10 @@ def _remove_prefix(self, path: str) -> str: return path[len(prefix) :] if isinstance(prefix, Pattern): - path = re.sub(prefix, "", path) + # Only strip a match at the start of the path, and only once + match = prefix.match(path) + if match: + path = path[match.end() :] # When using regexes, we might get into a point where everything is removed # from the string, so we check if it's empty and return /, since there's nothing 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..8cfa8d3227b 100644 --- a/tests/functional/event_handler/required_dependencies/test_api_gateway.py +++ b/tests/functional/event_handler/required_dependencies/test_api_gateway.py @@ -1323,6 +1323,29 @@ def foo(): ... assert response["statusCode"] == 200 +@pytest.mark.parametrize( + "path", + [ + pytest.param("/v1/users/v2", id="prefix pattern also appears later in the path"), + pytest.param("/users/v2", id="prefix pattern only appears later in the path"), + ], +) +def test_remove_prefix_by_regex_only_strips_leading_match(path: str): + # GIVEN a regex strip_prefix that could also match a later path segment + app = ApiGatewayResolver(strip_prefixes=[re.compile(r"/v[1-9]+")]) + + @app.get("/users/") + def get_user(name: str): + return {"name": name} + + # WHEN calling the handler with the pattern repeated later in the path + response = app({"httpMethod": "GET", "path": path}, None) + + # THEN only a leading match is stripped, so the route matches and keeps the later segment + assert response["statusCode"] == 200 + assert json.loads(response["body"]) == {"name": "v2"} + + def test_empty_path_when_using_regexes(): app = ApiGatewayResolver(strip_prefixes=[re.compile(r"/(dev|stg)")]) From 9aa1344f73c018ef2c80603f3bf448e954323c94 Mon Sep 17 00:00:00 2001 From: Gavin MacFadyen Date: Mon, 19 Oct 2026 09:47:08 -0300 Subject: [PATCH 2/8] fix(event_handler): match literal characters in route rules literally --- .../event_handler/api_gateway.py | 8 +++- .../required_dependencies/test_api_gateway.py | 43 +++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/aws_lambda_powertools/event_handler/api_gateway.py b/aws_lambda_powertools/event_handler/api_gateway.py index 9ad3bc918a6..0cb993864e3 100644 --- a/aws_lambda_powertools/event_handler/api_gateway.py +++ b/aws_lambda_powertools/event_handler/api_gateway.py @@ -2836,7 +2836,13 @@ def _compile_regex(rule: str, base_regex: str = _ROUTE_REGEX): NOTE: See #520 for context """ - rule_regex: str = re.sub(_DYNAMIC_ROUTE_PATTERN, _NAMED_GROUP_BOUNDARY_PATTERN, rule) + # Escape the literal parts of the rule so characters like "." or "+" only match themselves + rule_regex: str = "".join( + re.sub(_DYNAMIC_ROUTE_PATTERN, _NAMED_GROUP_BOUNDARY_PATTERN, part) + if re.fullmatch(_DYNAMIC_ROUTE_PATTERN, part) + else re.escape(part) + for part in re.split(_DYNAMIC_ROUTE_PATTERN, rule) + ) return re.compile(base_regex.format(rule_regex)) def _to_proxy_event(self, event: dict) -> BaseProxyEvent: 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 8cfa8d3227b..3f926e4002d 100644 --- a/tests/functional/event_handler/required_dependencies/test_api_gateway.py +++ b/tests/functional/event_handler/required_dependencies/test_api_gateway.py @@ -1146,6 +1146,49 @@ def test_debug_print_event(capsys): assert json.loads(output) == event +@pytest.mark.parametrize( + "rule, path, expected_status_code", + [ + pytest.param("/items.json", "/items.json", 200, id="dot matches a literal dot"), + pytest.param("/items.json", "/itemsXjson", 404, id="dot does not match any character"), + pytest.param("/a+b", "/a+b", 200, id="plus matches a literal plus"), + pytest.param("/a|b", "/a|b", 200, id="pipe matches a literal pipe"), + pytest.param("/a|b", "/abc", 404, id="pipe is not an alternation"), + ], +) +def test_route_rule_literal_characters_match_literally(rule: str, path: str, expected_status_code: int): + # GIVEN a route whose rule contains characters that are special in regex + app = ApiGatewayResolver() + + @app.get(rule) + def handler(): + return {} + + # WHEN calling the event handler + result = app({"httpMethod": "GET", "path": path}, None) + + # THEN the rule is matched literally + assert result["statusCode"] == expected_status_code + + +def test_route_rule_escapes_literals_around_dynamic_parameters(): + # GIVEN a dynamic route with a literal dot after the parameter + app = ApiGatewayResolver() + + @app.get("/files/.json") + def get_file(name: str): + return {"name": name} + + # WHEN calling the event handler with a matching and a non-matching path + matched = app({"httpMethod": "GET", "path": "/files/report.json"}, None) + not_matched = app({"httpMethod": "GET", "path": "/files/reportXjson"}, None) + + # THEN the parameter is still captured and the dot only matches a literal dot + assert matched["statusCode"] == 200 + assert json.loads(matched["body"]) == {"name": "report"} + assert not_matched["statusCode"] == 404 + + def test_similar_dynamic_routes(): # GIVEN app = ApiGatewayResolver() From 03397185fb78be4a9ce3d48751cb1a073002662a Mon Sep 17 00:00:00 2001 From: Gavin MacFadyen Date: Mon, 19 Oct 2026 09:49:07 -0300 Subject: [PATCH 3/8] fix(event_handler): respect q=0 and casing in Accept-Encoding before gzipping --- .../event_handler/api_gateway.py | 22 ++++++++++++---- .../required_dependencies/test_api_gateway.py | 26 +++++++++++++++++++ 2 files changed, 43 insertions(+), 5 deletions(-) diff --git a/aws_lambda_powertools/event_handler/api_gateway.py b/aws_lambda_powertools/event_handler/api_gateway.py index 0cb993864e3..1eb051386d0 100644 --- a/aws_lambda_powertools/event_handler/api_gateway.py +++ b/aws_lambda_powertools/event_handler/api_gateway.py @@ -746,6 +746,20 @@ def _generate_operation_id(self) -> str: return operation_id +def _accepts_gzip(accept_encoding: str) -> bool: + """Returns True if an Accept-Encoding header accepts gzip, honouring casing and a zero q-value""" + for coding in accept_encoding.split(","): + name, _, params = coding.partition(";") + if name.strip().lower() != "gzip": + continue + _, _, weight = params.replace(" ", "").lower().partition("q=") + try: + return float(weight) > 0 if weight else True + except ValueError: + return False + return False + + class ResponseBuilder(Generic[ResponseEventT]): """Internally used Response builder""" @@ -798,11 +812,9 @@ def _has_compression_enabled( True if compression is enabled and the "gzip" encoding is accepted, False otherwise. """ encoding = event.resolved_headers_field.get("accept-encoding", "") - gzip_accepted = False - if isinstance(encoding, str): - gzip_accepted = "gzip" in encoding - elif isinstance(encoding, list): - gzip_accepted = "gzip" in ",".join(encoding) + if isinstance(encoding, list): + encoding = ",".join(encoding) + gzip_accepted = isinstance(encoding, str) and _accepts_gzip(encoding) if gzip_accepted: if response_compression is not None: 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 3f926e4002d..07cfc3a7e24 100644 --- a/tests/functional/event_handler/required_dependencies/test_api_gateway.py +++ b/tests/functional/event_handler/required_dependencies/test_api_gateway.py @@ -588,6 +588,32 @@ def return_text() -> Response: assert result["body"] == expected_value +@pytest.mark.parametrize( + "accept_encoding, expected_compressed", + [ + pytest.param("gzip;q=0", False, id="gzip refused with a zero weight"), + pytest.param("deflate, gzip; q=0.0", False, id="gzip refused with a zero weight and spaces"), + pytest.param("GZIP", True, id="gzip accepted in uppercase"), + pytest.param("deflate, gzip;q=0.5", True, id="gzip accepted with a non-zero weight"), + ], +) +def test_compress_respects_accept_encoding_weights_and_casing(accept_encoding: str, expected_compressed: bool): + # GIVEN a function with compress=True + app = ApiGatewayResolver() + + @app.get("/my/path", compress=True) + def return_text() -> Response: + return Response(200, content_types.TEXT_PLAIN, "Foo") + + # WHEN the client sends an Accept-Encoding header with a weight or different casing + event = {"path": "/my/path", "httpMethod": "GET", "headers": {"Accept-Encoding": accept_encoding}} + result = app(event, None) + + # THEN the response is only gzipped when the client accepts gzip + assert result["isBase64Encoded"] is expected_compressed + assert ("Content-Encoding" in result["multiValueHeaders"]) is expected_compressed + + def test_compress_no_accept_encoding_null_headers(): # GIVEN a function with compress=True # AND the request has no headers From 798a5b2ee6bbae8ea17af28e5dd222f6bca51660 Mon Sep 17 00:00:00 2001 From: Gavin MacFadyen Date: Mon, 19 Oct 2026 09:50:10 -0300 Subject: [PATCH 4/8] fix(event_handler): base64 encode bytes bodies that have a JSON content type --- .../event_handler/api_gateway.py | 4 ++-- .../required_dependencies/test_api_gateway.py | 17 +++++++++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/aws_lambda_powertools/event_handler/api_gateway.py b/aws_lambda_powertools/event_handler/api_gateway.py index 1eb051386d0..8ec6200c628 100644 --- a/aws_lambda_powertools/event_handler/api_gateway.py +++ b/aws_lambda_powertools/event_handler/api_gateway.py @@ -857,8 +857,8 @@ def build(self, event: ResponseEventT, cors: CORSConfig | None = None) -> dict[s """Build the full response dict to be returned by the lambda""" # We only apply the serializer when the content type is JSON and the - # body is not a str, to avoid double encoding - if self.response.is_json() and not isinstance(self.response.body, str): + # body is not a str or bytes, to avoid double encoding (bytes are base64 encoded below) + if self.response.is_json() and not isinstance(self.response.body, (str, bytes)): self.response.body = self.serializer(self.response.body) self._route(event, cors) 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 07cfc3a7e24..07a78f015a7 100644 --- a/tests/functional/event_handler/required_dependencies/test_api_gateway.py +++ b/tests/functional/event_handler/required_dependencies/test_api_gateway.py @@ -523,6 +523,23 @@ def test_response_is_json_with_multiple_json_content_types(): assert response.is_json() is True +def test_response_with_bytes_body_and_json_content_type_is_base64_encoded(): + # GIVEN a route that returns a bytes body with a JSON content type + app = ApiGatewayResolver() + body = b'{"a":1}' + + @app.get("/my/path") + def get_bytes() -> Response: + return Response(200, content_types.APPLICATION_JSON, body) + + # WHEN calling the event handler + result = app({"path": "/my/path", "httpMethod": "GET"}, None) + + # THEN the bytes are base64 encoded instead of being passed to the JSON serializer + assert result["isBase64Encoded"] is True + assert base64.b64decode(result["body"]) == body + + def test_compress(): # GIVEN a function that has compress=True # AND an event with a "Accept-Encoding" that include gzip From 1a97ef4bef308dcd9186c8ed43c55c3cddea5a75 Mon Sep 17 00:00:00 2001 From: Gavin MacFadyen Date: Mon, 19 Oct 2026 09:51:03 -0300 Subject: [PATCH 5/8] fix(event_handler): detect the JSON Content-Type header in any casing --- .../event_handler/api_gateway.py | 3 ++- .../required_dependencies/test_api_gateway.py | 16 ++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/aws_lambda_powertools/event_handler/api_gateway.py b/aws_lambda_powertools/event_handler/api_gateway.py index 8ec6200c628..df93dcee024 100644 --- a/aws_lambda_powertools/event_handler/api_gateway.py +++ b/aws_lambda_powertools/event_handler/api_gateway.py @@ -362,7 +362,8 @@ def is_json(self) -> bool: """ Returns True if the response is JSON, based on the Content-Type. """ - content_type = self.headers.get("Content-Type", "") + # Header names are case-insensitive, so match "content-type" in any casing + content_type = next((value for key, value in self.headers.items() if key.lower() == "content-type"), "") if isinstance(content_type, list): content_type = content_type[0] return content_type.startswith(DEFAULT_CONTENT_TYPE) 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 07a78f015a7..30a585f900d 100644 --- a/tests/functional/event_handler/required_dependencies/test_api_gateway.py +++ b/tests/functional/event_handler/required_dependencies/test_api_gateway.py @@ -540,6 +540,22 @@ def get_bytes() -> Response: assert base64.b64decode(result["body"]) == body +def test_response_with_lowercase_content_type_header_serializes_json_body(): + # GIVEN a route that sets the JSON content type with a lowercase header name + app = ApiGatewayResolver() + + @app.get("/my/path") + def get_lowercase_header() -> Response: + return Response(200, headers={"content-type": content_types.APPLICATION_JSON}, body={"a": 1}) + + # WHEN calling the event handler + result = app({"path": "/my/path", "httpMethod": "GET"}, None) + + # THEN the response is recognised as JSON and the body is serialized + assert isinstance(result["body"], str) + assert json.loads(result["body"]) == {"a": 1} + + def test_compress(): # GIVEN a function that has compress=True # AND an event with a "Accept-Encoding" that include gzip From 2b8d476a41a1b4a2234239df3d593fcb3ad74b95 Mon Sep 17 00:00:00 2001 From: Gavin MacFadyen Date: Mon, 19 Oct 2026 09:52:02 -0300 Subject: [PATCH 6/8] fix(event_handler): add Vary: Origin when CORS echoes a specific origin --- .../event_handler/api_gateway.py | 12 ++++- .../required_dependencies/test_api_gateway.py | 53 +++++++++++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/aws_lambda_powertools/event_handler/api_gateway.py b/aws_lambda_powertools/event_handler/api_gateway.py index df93dcee024..260b080fb74 100644 --- a/aws_lambda_powertools/event_handler/api_gateway.py +++ b/aws_lambda_powertools/event_handler/api_gateway.py @@ -242,6 +242,9 @@ def to_dict(self, origin: str | None) -> dict[str, str]: headers["Access-Control-Max-Age"] = str(self.max_age) if origin != "*" and self.allow_credentials is True: headers["Access-Control-Allow-Credentials"] = "true" + if origin != "*": + # The response echoes the request Origin, so shared caches must not reuse it for other origins + headers["Vary"] = "Origin" return headers def allowed_origin(self, extracted_origin: str) -> str | None: @@ -780,7 +783,14 @@ def _add_cors(self, event: ResponseEventT, cors: CORSConfig): origin = cors.allowed_origin(extracted_origin_header) if origin is not None: - self.response.headers.update(cors.to_dict(origin)) + cors_headers = cors.to_dict(origin) + # Keep any Vary value the route already set, and add Origin to it + existing_vary = self.response.headers.get("Vary") + if isinstance(existing_vary, list): + existing_vary = ", ".join(existing_vary) + if existing_vary and "Vary" in cors_headers: + cors_headers["Vary"] = f"{existing_vary}, Origin" + self.response.headers.update(cors_headers) def _add_cache_control(self, cache_control: str): """Set the specified cache control headers for 200 http responses. For non-200 `no-cache` is used.""" 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 30a585f900d..2a0076ca152 100644 --- a/tests/functional/event_handler/required_dependencies/test_api_gateway.py +++ b/tests/functional/event_handler/required_dependencies/test_api_gateway.py @@ -843,6 +843,59 @@ def get_with_cors(): assert "Access-Control-Allow-Origin" not in headers +def test_cors_adds_vary_origin_when_echoing_a_specific_origin(): + # GIVEN a cors configuration with more than one allowed origin + cors_config = CORSConfig(allow_origin="https://a.com", extra_origins=["https://b.com"]) + app = ApiGatewayResolver(cors=cors_config) + + @app.get("/cors") + def get_with_cors(): + return {} + + # WHEN calling the event handler with one of the allowed origins + event = {"path": "/cors", "httpMethod": "GET", "headers": {"Origin": "https://a.com"}} + result = app(event, None) + + # THEN the echoed origin comes with Vary: Origin so shared caches don't reuse it for other origins + headers = result["multiValueHeaders"] + assert headers["Access-Control-Allow-Origin"] == ["https://a.com"] + assert headers["Vary"] == ["Origin"] + + +def test_cors_keeps_existing_vary_header_when_adding_origin(): + # GIVEN a route that already sets a Vary header + app = ApiGatewayResolver(cors=CORSConfig(allow_origin="https://a.com")) + + @app.get("/cors") + def get_with_vary() -> Response: + return Response(200, content_types.APPLICATION_JSON, "{}", headers={"Vary": "Accept-Encoding"}) + + # WHEN calling the event handler with the allowed origin + event = {"path": "/cors", "httpMethod": "GET", "headers": {"Origin": "https://a.com"}} + result = app(event, None) + + # THEN Origin is added to the existing Vary value instead of replacing it + assert result["multiValueHeaders"]["Vary"] == ["Accept-Encoding, Origin"] + + +def test_cors_does_not_add_vary_origin_for_wildcard_origin(): + # GIVEN a cors configuration that allows every origin + app = ApiGatewayResolver(cors=CORSConfig(allow_origin="*")) + + @app.get("/cors") + def get_with_cors(): + return {} + + # WHEN calling the event handler with any origin + event = {"path": "/cors", "httpMethod": "GET", "headers": {"Origin": "https://a.com"}} + result = app(event, None) + + # THEN the wildcard response is the same for every origin, so no Vary header is needed + headers = result["multiValueHeaders"] + assert headers["Access-Control-Allow-Origin"] == ["*"] + assert "Vary" not in headers + + def test_custom_cors_config(): # GIVEN a custom cors configuration allow_header = ["foo2"] From d1b797c8a5bed02ba676500f6cdd4b160992c502 Mon Sep 17 00:00:00 2001 From: Gavin MacFadyen Date: Thu, 22 Oct 2026 17:42:47 -0300 Subject: [PATCH 7/8] Revert "fix(event_handler): match literal characters in route rules literally" This reverts commit 9aa1344f73c018ef2c80603f3bf448e954323c94. --- .../event_handler/api_gateway.py | 8 +--- .../required_dependencies/test_api_gateway.py | 43 ------------------- 2 files changed, 1 insertion(+), 50 deletions(-) diff --git a/aws_lambda_powertools/event_handler/api_gateway.py b/aws_lambda_powertools/event_handler/api_gateway.py index 260b080fb74..3e06c653e52 100644 --- a/aws_lambda_powertools/event_handler/api_gateway.py +++ b/aws_lambda_powertools/event_handler/api_gateway.py @@ -2859,13 +2859,7 @@ def _compile_regex(rule: str, base_regex: str = _ROUTE_REGEX): NOTE: See #520 for context """ - # Escape the literal parts of the rule so characters like "." or "+" only match themselves - rule_regex: str = "".join( - re.sub(_DYNAMIC_ROUTE_PATTERN, _NAMED_GROUP_BOUNDARY_PATTERN, part) - if re.fullmatch(_DYNAMIC_ROUTE_PATTERN, part) - else re.escape(part) - for part in re.split(_DYNAMIC_ROUTE_PATTERN, rule) - ) + rule_regex: str = re.sub(_DYNAMIC_ROUTE_PATTERN, _NAMED_GROUP_BOUNDARY_PATTERN, rule) return re.compile(base_regex.format(rule_regex)) def _to_proxy_event(self, event: dict) -> BaseProxyEvent: 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 2a0076ca152..7441008ceae 100644 --- a/tests/functional/event_handler/required_dependencies/test_api_gateway.py +++ b/tests/functional/event_handler/required_dependencies/test_api_gateway.py @@ -1258,49 +1258,6 @@ def test_debug_print_event(capsys): assert json.loads(output) == event -@pytest.mark.parametrize( - "rule, path, expected_status_code", - [ - pytest.param("/items.json", "/items.json", 200, id="dot matches a literal dot"), - pytest.param("/items.json", "/itemsXjson", 404, id="dot does not match any character"), - pytest.param("/a+b", "/a+b", 200, id="plus matches a literal plus"), - pytest.param("/a|b", "/a|b", 200, id="pipe matches a literal pipe"), - pytest.param("/a|b", "/abc", 404, id="pipe is not an alternation"), - ], -) -def test_route_rule_literal_characters_match_literally(rule: str, path: str, expected_status_code: int): - # GIVEN a route whose rule contains characters that are special in regex - app = ApiGatewayResolver() - - @app.get(rule) - def handler(): - return {} - - # WHEN calling the event handler - result = app({"httpMethod": "GET", "path": path}, None) - - # THEN the rule is matched literally - assert result["statusCode"] == expected_status_code - - -def test_route_rule_escapes_literals_around_dynamic_parameters(): - # GIVEN a dynamic route with a literal dot after the parameter - app = ApiGatewayResolver() - - @app.get("/files/.json") - def get_file(name: str): - return {"name": name} - - # WHEN calling the event handler with a matching and a non-matching path - matched = app({"httpMethod": "GET", "path": "/files/report.json"}, None) - not_matched = app({"httpMethod": "GET", "path": "/files/reportXjson"}, None) - - # THEN the parameter is still captured and the dot only matches a literal dot - assert matched["statusCode"] == 200 - assert json.loads(matched["body"]) == {"name": "report"} - assert not_matched["statusCode"] == 404 - - def test_similar_dynamic_routes(): # GIVEN app = ApiGatewayResolver() From ae9262183314553f96f46c363e6cc25485dca604 Mon Sep 17 00:00:00 2001 From: Gavin MacFadyen Date: Thu, 22 Oct 2026 17:44:38 -0300 Subject: [PATCH 8/8] test(event_handler): cover documented catch-all regex routes --- .../required_dependencies/test_api_gateway.py | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) 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 7441008ceae..598e1724ce5 100644 --- a/tests/functional/event_handler/required_dependencies/test_api_gateway.py +++ b/tests/functional/event_handler/required_dependencies/test_api_gateway.py @@ -1258,6 +1258,35 @@ def test_debug_print_event(capsys): assert json.loads(output) == event +@pytest.mark.parametrize( + "rule, path, expected_route_keys", + [ + pytest.param(".+", "/any/deep/path", {}, id="catch-all matches any path"), + pytest.param("/files/.+", "/files/reports/2026/summary.pdf", {}, id="catch-all after a static prefix"), + pytest.param( + "/users//files/.+", + "/users/123/files/docs/report.txt", + {"user_id": "123"}, + id="catch-all after a dynamic parameter", + ), + ], +) +def test_route_rule_supports_documented_catch_all_regex(rule: str, path: str, expected_route_keys: dict): + # GIVEN a route that uses a regex catch-all, as shown in the "Catch-all routes" docs + app = APIGatewayRestResolver() + + @app.get(rule) + def handler(**route_keys): + return route_keys + + # WHEN calling the event handler with a nested path + result = app({"httpMethod": "GET", "path": path}, None) + + # THEN the regex in the rule matches the whole path and dynamic parameters are still captured + assert result["statusCode"] == 200 + assert json.loads(result["body"]) == expected_route_keys + + def test_similar_dynamic_routes(): # GIVEN app = ApiGatewayResolver()