diff --git a/src/aws_durable_execution_sdk_python_testing/web/models.py b/src/aws_durable_execution_sdk_python_testing/web/models.py index d5f2779..86012b7 100644 --- a/src/aws_durable_execution_sdk_python_testing/web/models.py +++ b/src/aws_durable_execution_sdk_python_testing/web/models.py @@ -16,7 +16,8 @@ from aws_durable_execution_sdk_python_testing.web.routes import Route from aws_durable_execution_sdk_python_testing.web.serialization import ( AwsRestJsonDeserializer, - AwsRestJsonSerializer, + JSONSerializer, + Serializer, ) @@ -146,54 +147,20 @@ class HTTPResponse: status_code: int headers: dict[str, str] body: dict[str, Any] + serializer: Serializer = JSONSerializer() - def body_to_bytes(self, operation_name: str | None = None) -> bytes: + def body_to_bytes(self) -> bytes: """Convert response dict body to bytes for HTTP transmission. - Args: - operation_name: Optional AWS operation name for boto serialization - Returns: bytes: Serialized response body Raises: InvalidParameterValueException: If serialization fails with both AWS and JSON methods """ - # Try AWS serialization first if operation_name provided - if operation_name: - try: - serializer = AwsRestJsonSerializer.create(operation_name) - result = serializer.to_bytes(self.body) - logger.debug( - "Successfully serialized response using AWS serializer for %s", - operation_name, - ) - return result # noqa: TRY300 - except InvalidParameterValueException as e: - logger.warning( - "AWS serialization failed for %s, falling back to JSON: %s", - operation_name, - e, - ) - # Fall back to standard JSON - try: - result = json.dumps(self.body, separators=(",", ":")).encode( - "utf-8" - ) - logger.debug("Successfully serialized response using JSON fallback") - return result # noqa: TRY300 - except (TypeError, ValueError) as json_error: - msg = f"Both AWS and JSON serialization failed: AWS error: {e}, JSON error: {json_error}" - raise InvalidParameterValueException(msg) from json_error - else: - # Use standard JSON serialization - try: - result = json.dumps(self.body, separators=(",", ":")).encode("utf-8") - logger.debug("Successfully serialized response using standard JSON") - return result # noqa: TRY300 - except (TypeError, ValueError) as e: - msg = f"JSON serialization failed: {e}" - raise InvalidParameterValueException(msg) from e + result = self.serializer.to_bytes(data=self.body) + logger.debug("Serialized result - before: %s, after: %s", self.body, result) + return result @classmethod def from_dict( diff --git a/src/aws_durable_execution_sdk_python_testing/web/serialization.py b/src/aws_durable_execution_sdk_python_testing/web/serialization.py index 7af7f71..93ae247 100644 --- a/src/aws_durable_execution_sdk_python_testing/web/serialization.py +++ b/src/aws_durable_execution_sdk_python_testing/web/serialization.py @@ -9,6 +9,7 @@ import json import os from typing import Any, Protocol +from datetime import datetime import aws_durable_execution_sdk_python import botocore.loaders # type: ignore @@ -57,6 +58,29 @@ def from_bytes(self, data: bytes) -> dict[str, Any]: ... # pragma: no cover +class JSONSerializer: + """JSON serializer with datetime support.""" + + def to_bytes(self, data: Any) -> bytes: + """Serialize data to JSON bytes.""" + try: + json_string = json.dumps( + data, separators=(",", ":"), default=self._default_handler + ) + return json_string.encode("utf-8") + except (TypeError, ValueError) as e: + raise InvalidParameterValueException( + f"Failed to serialize data to JSON: {str(e)}" + ) + + def _default_handler(self, obj: Any) -> str: + """Handle non-permitive objects.""" + if isinstance(obj, datetime): + return obj.isoformat() + # Raise TypeError for unsupported types + raise TypeError(f"Object of type {type(obj).__name__} is not JSON serializable") + + class AwsRestJsonSerializer: """AWS rest-json serializer using boto.""" diff --git a/tests/web/models_test.py b/tests/web/models_test.py index 8dbd2cb..8148736 100644 --- a/tests/web/models_test.py +++ b/tests/web/models_test.py @@ -260,6 +260,27 @@ def test_http_request_from_bytes_standard_json() -> None: assert request.body == test_data +def test_http_get_request_from_bytes_ignore_body() -> None: + """Test HTTPRequest.from_bytes with standard JSON deserialization.""" + test_data = {"key": "value", "number": 42} + body_bytes = json.dumps(test_data).encode("utf-8") + + path = Route.from_string("/test") + request = HTTPRequest.from_bytes( + body_bytes=body_bytes, + method="GET", + path=path, + headers={"Content-Type": "application/json"}, + query_params={"param": ["value"]}, + ) + + assert request.method == "GET" + assert request.path == path + assert request.headers == {"Content-Type": "application/json"} + assert request.query_params == {"param": ["value"]} + assert request.body == {} + + def test_http_request_from_bytes_minimal_params() -> None: """Test HTTPRequest.from_bytes with minimal parameters.""" test_data = {"message": "hello"} @@ -413,33 +434,6 @@ def test_http_response_body_to_bytes_compact_format() -> None: assert "\n" not in body_str # No newlines -def test_http_response_body_to_bytes_aws_operation_fallback() -> None: - """Test body_to_bytes with AWS operation that falls back to JSON.""" - test_data = {"ExecutionId": "test-execution-id", "Status": "SUCCEEDED"} - response = HTTPResponse(status_code=200, headers={}, body=test_data) - - # Use a non-existent operation name to trigger fallback - body_bytes = response.body_to_bytes(operation_name="NonExistentOperation") - - # Should still work via JSON fallback - assert isinstance(body_bytes, bytes) - parsed_data = json.loads(body_bytes.decode("utf-8")) - assert parsed_data == test_data - - -def test_http_response_body_to_bytes_invalid_data() -> None: - """Test body_to_bytes with data that can't be JSON serialized.""" - # Create data with non-serializable object - - test_data = {"timestamp": datetime.datetime.now(datetime.UTC)} - response = HTTPResponse(status_code=200, headers={}, body=test_data) - - with pytest.raises( - InvalidParameterValueException, match="JSON serialization failed" - ): - response.body_to_bytes() - - def test_http_response_body_to_bytes_empty_body() -> None: """Test body_to_bytes with empty body.""" response = HTTPResponse(status_code=204, headers={}, body={}) @@ -465,28 +459,6 @@ def test_http_response_body_to_bytes_complex_data() -> None: assert parsed_data == complex_data -def test_http_response_body_to_bytes_aws_operation_success() -> None: - """Test body_to_bytes with valid AWS operation (if available).""" - # This test will use AWS serialization if available, otherwise fall back to JSON - test_data = { - "ExecutionId": "test-execution-id", - "Status": "SUCCEEDED", - "Result": "test-result", - } - response = HTTPResponse(status_code=200, headers={}, body=test_data) - - # Try with a real AWS operation name - body_bytes = response.body_to_bytes(operation_name="StartDurableExecution") - - # Should get valid bytes regardless of AWS vs JSON serialization - assert isinstance(body_bytes, bytes) - assert len(body_bytes) > 0 - - # Should be valid JSON (either from AWS serialization or fallback) - parsed_data = json.loads(body_bytes.decode("utf-8")) - assert isinstance(parsed_data, dict) - - # Tests for HTTPResponse.from_dict method @@ -627,47 +599,21 @@ def test_http_request_from_bytes_aws_deserialization_fallback_error() -> None: ) -def test_http_response_body_to_bytes_aws_serialization_success() -> None: - """Test HTTPResponse.body_to_bytes with successful AWS serialization.""" - - test_data = {"ExecutionId": "test-id", "Status": "SUCCEEDED"} - response = HTTPResponse(status_code=200, headers={}, body=test_data) - expected_bytes = b'{"ExecutionId":"test-id","Status":"SUCCEEDED"}' - - # Mock successful AWS serialization - mock_serializer = Mock() - mock_serializer.to_bytes.return_value = expected_bytes - - with patch( - "aws_durable_execution_sdk_python_testing.web.models.AwsRestJsonSerializer.create", - return_value=mock_serializer, - ): - result = response.body_to_bytes(operation_name="StartDurableExecution") - - assert result == expected_bytes - mock_serializer.to_bytes.assert_called_once_with(test_data) - - -def test_http_response_body_to_bytes_aws_serialization_fallback_error() -> None: - """Test HTTPResponse.body_to_bytes when both AWS and JSON serialization fail.""" +def test_http_response_body_to_bytes_serialization_error() -> None: + """Test HTTPResponse.body_to_bytes when JSON serialization fail.""" # Create data that can't be JSON serialized - test_data = {"timestamp": datetime.datetime.now(datetime.UTC)} - response = HTTPResponse(status_code=200, headers={}, body=test_data) + class CustomObject: + pass - # Mock AWS serialization failure - mock_serializer = Mock() - mock_serializer.to_bytes.side_effect = InvalidParameterValueException("AWS failed") + test_data = {"custom": CustomObject()} + response = HTTPResponse(status_code=200, headers={}, body=test_data) - with patch( - "aws_durable_execution_sdk_python_testing.web.models.AwsRestJsonSerializer.create", - return_value=mock_serializer, + with pytest.raises( + InvalidParameterValueException, + match="Failed to serialize data to JSON: Object of type CustomObject is not JSON serializable", ): - with pytest.raises( - InvalidParameterValueException, - match="Both AWS and JSON serialization failed", - ): - response.body_to_bytes(operation_name="StartDurableExecution") + response.body_to_bytes() # Tests for HTTPResponse.create_error_from_exception method diff --git a/tests/web/serialization_test.py b/tests/web/serialization_test.py index 43653ba..da518ae 100644 --- a/tests/web/serialization_test.py +++ b/tests/web/serialization_test.py @@ -5,11 +5,14 @@ from unittest.mock import Mock, patch import pytest +import json +from datetime import datetime from aws_durable_execution_sdk_python_testing.exceptions import ( InvalidParameterValueException, ) from aws_durable_execution_sdk_python_testing.web.serialization import ( + JSONSerializer, AwsRestJsonDeserializer, AwsRestJsonSerializer, ) @@ -384,3 +387,194 @@ def test_aws_rest_json_deserializer_should_raise_error_when_json_parsing_fails() deserializer.from_bytes(test_bytes) assert "Failed to deserialize data for test" in str(exc_info.value) + + +def test_serialize_simple_dict(): + """Test serialization of simple dictionary.""" + serializer = JSONSerializer() + data = {"key": "value", "number": 42} + result = serializer.to_bytes(data) + + expected = b'{"key":"value","number":42}' + assert result == expected + assert isinstance(result, bytes) + assert json.loads(result.decode("utf-8")) == data + + +def test_serialize_datetime(): + """Test serialization of datetime objects.""" + serializer = JSONSerializer() + now = datetime(2025, 11, 5, 16, 30, 9, 895000) + data = {"timestamp": now} + + result = serializer.to_bytes(data) + expected = b'{"timestamp":"2025-11-05T16:30:09.895000"}' + + assert result == expected + assert isinstance(result, bytes) + + deserialized = json.loads(result.decode("utf-8")) + assert deserialized["timestamp"] == "2025-11-05T16:30:09.895000" + + +def test_serialize_nested_datetime(): + """Test serialization of nested structures with datetime.""" + serializer = JSONSerializer() + now = datetime(2025, 11, 5, 16, 30, 9) + data = { + "event": "user_login", + "timestamp": now, + "metadata": {"created_at": now, "updated_at": now}, + } + + result = serializer.to_bytes(data) + expected = ( + b'{"event":"user_login",' + b'"timestamp":"2025-11-05T16:30:09",' + b'"metadata":{"created_at":"2025-11-05T16:30:09",' + b'"updated_at":"2025-11-05T16:30:09"}}' + ) + + assert result == expected + + deserialized = json.loads(result.decode("utf-8")) + assert deserialized["timestamp"] == now.isoformat() + assert deserialized["metadata"]["created_at"] == now.isoformat() + + +def test_serialize_list_with_datetime(): + """Test serialization of list containing datetime.""" + serializer = JSONSerializer() + now = datetime(2025, 11, 5, 16, 30, 9) + data = { + "events": [{"time": now, "action": "login"}, {"time": now, "action": "logout"}] + } + + result = serializer.to_bytes(data) + expected = ( + b'{"events":[' + b'{"time":"2025-11-05T16:30:09","action":"login"},' + b'{"time":"2025-11-05T16:30:09","action":"logout"}' + b"]}" + ) + + assert result == expected + + deserialized = json.loads(result.decode("utf-8")) + assert deserialized["events"][0]["time"] == now.isoformat() + assert deserialized["events"][1]["time"] == now.isoformat() + + +def test_serialize_mixed_types(): + """Test serialization of mixed data types.""" + serializer = JSONSerializer() + now = datetime(2025, 11, 5, 16, 30, 9) + data = { + "string": "test", + "number": 42, + "float": 3.14, + "boolean": True, + "null": None, + "list": [1, 2, 3], + "datetime": now, + } + + result = serializer.to_bytes(data) + expected = ( + b'{"string":"test",' + b'"number":42,' + b'"float":3.14,' + b'"boolean":true,' + b'"null":null,' + b'"list":[1,2,3],' + b'"datetime":"2025-11-05T16:30:09"}' + ) + + assert result == expected + + deserialized = json.loads(result.decode("utf-8")) + assert deserialized["string"] == "test" + assert deserialized["number"] == 42 + assert deserialized["float"] == 3.14 + assert deserialized["boolean"] is True + assert deserialized["null"] is None + assert deserialized["list"] == [1, 2, 3] + assert deserialized["datetime"] == now.isoformat() + + +def test_serialize_returns_bytes(): + """Test that serialization returns bytes.""" + serializer = JSONSerializer() + data = {"test": "value"} + result = serializer.to_bytes(data) + expected = b'{"test":"value"}' + + assert result == expected + assert isinstance(result, bytes) + + +def test_serialize_non_serializable_object_raises_exception(): + """Test that non-serializable objects raise InvalidParameterValueException.""" + serializer = JSONSerializer() + + class CustomObject: + pass + + data = {"custom": CustomObject()} + + with pytest.raises(InvalidParameterValueException) as exc_info: + serializer.to_bytes(data) + + assert ( + "Failed to serialize data to JSON: Object of type CustomObject is not JSON serializable" + in str(exc_info.value) + ) + + +def test_serialize_circular_reference_raises_exception(): + """Test that circular references raise InvalidParameterValueException.""" + serializer = JSONSerializer() + data = {"key": "value"} + data["self"] = data # Create circular reference + + with pytest.raises(InvalidParameterValueException) as exc_info: + serializer.to_bytes(data) + + assert "Failed to serialize data to JSON" in str(exc_info.value) + + +def test_serialize_datetime_with_microseconds(): + """Test serialization of datetime with microseconds.""" + serializer = JSONSerializer() + now = datetime(2025, 11, 5, 16, 30, 9, 123456) + data = {"timestamp": now} + + result = serializer.to_bytes(data) + expected = b'{"timestamp":"2025-11-05T16:30:09.123456"}' + + assert result == expected + + +def test_serialize_datetime_without_microseconds(): + """Test serialization of datetime without microseconds.""" + serializer = JSONSerializer() + now = datetime(2025, 11, 5, 16, 30, 9) + data = {"timestamp": now} + + result = serializer.to_bytes(data) + expected = b'{"timestamp":"2025-11-05T16:30:09"}' + + assert result == expected + + +def test_serialize_multiple_datetimes(): + """Test multiple datetime objects.""" + serializer = JSONSerializer() + dt1 = datetime(2025, 1, 1, 0, 0, 0) + dt2 = datetime(2025, 12, 31, 23, 59, 59) + + data = {"start": dt1, "end": dt2} + result = serializer.to_bytes(data) + expected = b'{"start":"2025-01-01T00:00:00",' b'"end":"2025-12-31T23:59:59"}' + + assert result == expected