diff --git a/samcli/local/lambda_service/local_lambda_http_service.py b/samcli/local/lambda_service/local_lambda_http_service.py index eb702073edc..044bd39a791 100644 --- a/samcli/local/lambda_service/local_lambda_http_service.py +++ b/samcli/local/lambda_service/local_lambda_http_service.py @@ -96,21 +96,21 @@ def create(self): # Durable functions endpoints self._app.add_url_rule( - "/2025-12-01/durable-executions/", + "/2025-12-01/durable-executions/", endpoint="get_durable_execution", view_func=self._get_durable_execution_handler, methods=["GET"], ) self._app.add_url_rule( - "/2025-12-01/durable-executions//history", + "/2025-12-01/durable-executions//history", endpoint="get_durable_execution_history", view_func=self._get_durable_execution_history_handler, methods=["GET"], ) self._app.add_url_rule( - "/2025-12-01/durable-executions//stop", + "/2025-12-01/durable-executions//stop", endpoint="stop_durable_execution", view_func=self._stop_durable_execution_handler, methods=["POST"], @@ -118,21 +118,21 @@ def create(self): # Callback endpoints self._app.add_url_rule( - "/2025-12-01/durable-execution-callbacks//succeed", + "/2025-12-01/durable-execution-callbacks//succeed", endpoint="send_callback_success", view_func=self._send_callback_success_handler, methods=["POST"], ) self._app.add_url_rule( - "/2025-12-01/durable-execution-callbacks//fail", + "/2025-12-01/durable-execution-callbacks//fail", endpoint="send_callback_failure", view_func=self._send_callback_failure_handler, methods=["POST"], ) self._app.add_url_rule( - "/2025-12-01/durable-execution-callbacks//heartbeat", + "/2025-12-01/durable-execution-callbacks//heartbeat", endpoint="send_callback_heartbeat", view_func=self._send_callback_heartbeat_handler, methods=["POST"], diff --git a/tests/unit/local/lambda_service/test_local_lambda_http_service.py b/tests/unit/local/lambda_service/test_local_lambda_http_service.py index 1d2e5d535d9..4fcde7c1016 100644 --- a/tests/unit/local/lambda_service/test_local_lambda_http_service.py +++ b/tests/unit/local/lambda_service/test_local_lambda_http_service.py @@ -3,6 +3,7 @@ from datetime import datetime from unittest import TestCase from unittest.mock import ANY, Mock, call, patch +from urllib.parse import quote from parameterized import parameterized @@ -64,42 +65,42 @@ def test_create_service_endpoints(self, flask_mock, error_handling_mock): # Verify durable functions endpoints were added app_mock.add_url_rule.assert_any_call( - "/2025-12-01/durable-executions/", + "/2025-12-01/durable-executions/", endpoint="get_durable_execution", view_func=service._get_durable_execution_handler, methods=["GET"], ) app_mock.add_url_rule.assert_any_call( - "/2025-12-01/durable-executions//history", + "/2025-12-01/durable-executions//history", endpoint="get_durable_execution_history", view_func=service._get_durable_execution_history_handler, methods=["GET"], ) app_mock.add_url_rule.assert_any_call( - "/2025-12-01/durable-executions//stop", + "/2025-12-01/durable-executions//stop", endpoint="stop_durable_execution", view_func=service._stop_durable_execution_handler, methods=["POST"], ) app_mock.add_url_rule.assert_any_call( - "/2025-12-01/durable-execution-callbacks//succeed", + "/2025-12-01/durable-execution-callbacks//succeed", endpoint="send_callback_success", view_func=service._send_callback_success_handler, methods=["POST"], ) app_mock.add_url_rule.assert_any_call( - "/2025-12-01/durable-execution-callbacks//fail", + "/2025-12-01/durable-execution-callbacks//fail", endpoint="send_callback_failure", view_func=service._send_callback_failure_handler, methods=["POST"], ) app_mock.add_url_rule.assert_any_call( - "/2025-12-01/durable-execution-callbacks//heartbeat", + "/2025-12-01/durable-execution-callbacks//heartbeat", endpoint="send_callback_heartbeat", view_func=service._send_callback_heartbeat_handler, methods=["POST"], @@ -1313,3 +1314,130 @@ def test_invoke_request_handler_combines_headers_with_durable_execution_arn( ), } service_response_mock.assert_called_once_with("hello world", expected_headers, 200) + + +class TestDurableArnShapeCompatibility(TestCase): + """ + Routing must accept the documented Lambda ``DurableExecutionArn`` shape. + + Per the Lambda public API docs, ``DurableExecutionArn`` matches:: + + arn:([a-zA-Z0-9-]+):lambda:([a-zA-Z0-9-]+):(\\d{12}) + :function:([a-zA-Z0-9_-]+) + :(\\$LATEST(?:\\.PUBLISHED)?|[0-9]+) + /durable-execution/([a-zA-Z0-9_-]+)/([a-zA-Z0-9_-]+) + + https://docs.aws.amazon.com/lambda/latest/api/API_GetDurableExecution.html + + The shape contains characters that boto's REST-JSON serializer + percent-encodes inside a non-greedy URI label: ``/`` -> ``%2F``, + ``:`` -> ``%3A``, ``$`` -> ``%24``. Werkzeug decodes those back to + their literal forms before route matching, so the default ```` + converter (which does not match ``/``) cannot match the resulting + multi-segment path. Switching the route to ```` accepts the + documented shape end-to-end and remains backwards-compatible with the + legacy UUID-only shape and the transitional ``/`` + shape currently emitted by the durable-functions emulator. + + These tests drive the real Flask app via its test client to assert + routing reaches the correct handler with the decoded value, and that + the trailing-literal rules (``/history`` and ``/stop``) win over the + bare ```` rule on the same prefix. + """ + + DOC_ARN = ( + "arn:aws:lambda:us-east-1:123456789012" + ":function:myDurableFunction:$LATEST" + "/durable-execution/myExecutionName/01H8X7Y8Z9ABCDEFGHIJKLMNOP" + ) + PLACEHOLDER_ARN = "9a1bc86c-40b9-4688-86b4-d7ecaca41579/2a8bc667-bc0b-4b5e-8c78-26db9c5b4e33" + LEGACY_ARN = "9a1bc86c-40b9-4688-86b4-d7ecaca41579" + + @patch("samcli.local.lambda_service.local_lambda_http_service.LocalLambdaHttpService._construct_error_handling") + def _build_service(self, error_handling_mock): + lambda_runner_mock = Mock() + service = LocalLambdaHttpService(lambda_runner=lambda_runner_mock, port=3000, host="127.0.0.1") + service.create() + return service + + @parameterized.expand( + [ + ("doc_shape", DOC_ARN), + ("placeholder", PLACEHOLDER_ARN), + ("legacy_uuid_only", LEGACY_ARN), + ] + ) + @patch.object(LocalLambdaHttpService, "_get_durable_execution_handler") + def test_get_durable_execution_routes_arn(self, _name, arn, handler_mock): + handler_mock.return_value = ("ok", 200) + service = self._build_service() + client = service._app.test_client() + + encoded = quote(arn, safe="") + response = client.get(f"/2025-12-01/durable-executions/{encoded}") + + # Pre-fix: 404 PathNotFoundLocally because cannot match a + # segment containing decoded "/" (or ":" / "$"). + self.assertEqual(response.status_code, 200) + handler_mock.assert_called_once_with(durable_execution_arn=arn) + + @parameterized.expand( + [ + ("doc_shape", DOC_ARN), + ("placeholder", PLACEHOLDER_ARN), + ("legacy_uuid_only", LEGACY_ARN), + ] + ) + @patch.object(LocalLambdaHttpService, "_get_durable_execution_history_handler") + def test_get_durable_execution_history_routes_arn(self, _name, arn, handler_mock): + handler_mock.return_value = ("ok", 200) + service = self._build_service() + client = service._app.test_client() + + encoded = quote(arn, safe="") + response = client.get(f"/2025-12-01/durable-executions/{encoded}/history") + + self.assertEqual(response.status_code, 200) + # Trailing literal /history must win over the bare rule. + handler_mock.assert_called_once_with(durable_execution_arn=arn) + + @parameterized.expand( + [ + ("doc_shape", DOC_ARN), + ("placeholder", PLACEHOLDER_ARN), + ("legacy_uuid_only", LEGACY_ARN), + ] + ) + @patch.object(LocalLambdaHttpService, "_stop_durable_execution_handler") + def test_stop_durable_execution_routes_arn(self, _name, arn, handler_mock): + handler_mock.return_value = ("ok", 200) + service = self._build_service() + client = service._app.test_client() + + encoded = quote(arn, safe="") + response = client.post(f"/2025-12-01/durable-executions/{encoded}/stop") + + self.assertEqual(response.status_code, 200) + handler_mock.assert_called_once_with(durable_execution_arn=arn) + + @parameterized.expand( + [ + ("succeed", "_send_callback_success_handler"), + ("fail", "_send_callback_failure_handler"), + ("heartbeat", "_send_callback_heartbeat_handler"), + ] + ) + def test_callback_routes_id_with_slash(self, action, handler_attr): + # Base64 callback IDs contain '/' which boto percent-encodes the + # same way as the doc-shape ARN's slashes. + decoded_id = "abc/def==" + encoded_id = quote(decoded_id, safe="") + with patch.object(LocalLambdaHttpService, handler_attr) as handler_mock: + handler_mock.return_value = ("ok", 200) + service = self._build_service() + client = service._app.test_client() + + response = client.post(f"/2025-12-01/durable-execution-callbacks/{encoded_id}/{action}") + + self.assertEqual(response.status_code, 200) + handler_mock.assert_called_once_with(callback_id=decoded_id)