diff --git a/reflectapi-demo/clients/python/tests/generated_code/test_advanced_edge_cases.py b/reflectapi-demo/clients/python/tests/generated_code/test_advanced_edge_cases.py index 16f18604..0f33b055 100644 --- a/reflectapi-demo/clients/python/tests/generated_code/test_advanced_edge_cases.py +++ b/reflectapi-demo/clients/python/tests/generated_code/test_advanced_edge_cases.py @@ -31,7 +31,6 @@ MyapiProtoPetsUpdateError as PetsUpdateError, MyapiProtoPetsRemoveError as PetsRemoveError, ) -from reflectapi_runtime import ReflectapiOption, ReflectapiOption as Option, Undefined from tests.model_helpers import ( aggressive_behavior, calm_behavior, @@ -204,45 +203,44 @@ def test_pet_with_extreme_dates(self): pass -class TestReflectapiOptionAdvancedCases: - """Test advanced ReflectapiOption scenarios with generated models.""" +class TestPartialFieldAdvancedCases: + """Test advanced partial-field (three-state) scenarios with generated models. + + Generated models with ``reflectapi::Option`` fields inherit from + ``ReflectapiPartialModel``: a field left unset is absent from the wire, + an explicit ``None`` serializes as ``null``, and ``model_fields_set`` + records which fields were provided. + """ def test_nested_optional_fields(self): """Test models with multiple levels of optional fields.""" - # Create update request with mix of defined and undefined fields + # Create update request with mix of set and unset fields request = PetsUpdateRequest( name="Complex Pet", kind=PetKindDog(type="dog", breed="Complex Breed"), - age=ReflectapiOption(5), - # behaviors is omitted (undefined) + age=5, + # behaviors is left unset ) - # Test serialization includes provided fields + # Serialization includes provided fields and omits unset ones json_data = json.loads(request.model_dump_json()) assert "name" in json_data assert "kind" in json_data - assert "age" in json_data - assert json_data["behaviors"] is None # Should be null when not provided - - def test_reflectapi_option_type_coercion(self): - """Test ReflectapiOption with type coercion edge cases.""" - # Test with different numeric types - numeric_values = [ - 42, # int - 42.0, # float - 42.5, # float with decimal - True, # bool (should be treated as 1) - False, # bool (should be treated as 0) - ] + assert json_data["age"] == 5 + assert "behaviors" not in json_data - for value in numeric_values: - request = PetsUpdateRequest(name="Type Test", age=ReflectapiOption(value)) + def test_partial_field_validates_like_a_plain_field(self): + """Partial fields get full Pydantic validation, not raw passthrough.""" + # Lax coercion accepts an integral float for an int field + assert PetsUpdateRequest(name="Type Test", age=42).age == 42 + assert PetsUpdateRequest(name="Type Test", age=42.0).age == 42 - # ReflectapiOption preserves the original value - assert request.age.unwrap() == value + # Non-integral floats are rejected like any other int field + with pytest.raises(ValidationError): + PetsUpdateRequest(name="Type Test", age=42.5) - def test_reflectapi_option_with_complex_nested_data(self): - """Test ReflectapiOption containing complex nested structures.""" + def test_partial_field_with_complex_nested_data(self): + """Test a partial field containing complex nested structures.""" complex_behaviors = [ calm_behavior(), aggressive_behavior(8.5, "Very aggressive"), @@ -251,34 +249,40 @@ def test_reflectapi_option_with_complex_nested_data(self): ] request = PetsUpdateRequest( - name="Complex Behavior Pet", behaviors=ReflectapiOption(complex_behaviors) + name="Complex Behavior Pet", behaviors=complex_behaviors ) - assert request.behaviors.is_some - assert len(request.behaviors.unwrap()) == 4 + assert "behaviors" in request.model_fields_set + assert len(request.behaviors) == 4 # Count BehaviorCalm instances - check the actual Behavior objects - behaviors = request.behaviors.unwrap() calm_count = sum( 1 - for b in behaviors + for b in request.behaviors if (hasattr(b, "root") and b.root == "Calm") or b == "Calm" ) assert calm_count == 2 - def test_multiple_undefined_fields_serialization(self): - """Test serialization with multiple undefined fields.""" + def test_multiple_unset_fields_serialization(self): + """Unset partial fields are omitted from the wire entirely.""" request = PetsUpdateRequest( name="Minimal Pet" - # All other fields left undefined + # All other fields left unset ) json_data = json.loads(request.model_dump_json()) - # All fields should be present due to the default values - assert json_data["name"] == "Minimal Pet" - assert json_data["kind"] is None # Default None - assert json_data["age"] is None # Default None - assert json_data["behaviors"] is None # Default None + assert json_data == {"name": "Minimal Pet"} + + def test_explicit_null_round_trips(self): + """An explicit None serializes as null, distinct from absent.""" + request = PetsUpdateRequest(name="Nullable Pet", age=None) + + json_data = json.loads(request.model_dump_json()) + assert json_data == {"name": "Nullable Pet", "age": None} + + parsed = PetsUpdateRequest.model_validate(json_data) + assert "age" in parsed.model_fields_set + assert "behaviors" not in parsed.model_fields_set class TestEnumEdgeCases: diff --git a/reflectapi-demo/clients/python/tests/integration/test_client_server_e2e.py b/reflectapi-demo/clients/python/tests/integration/test_client_server_e2e.py index bd364a2b..35113f67 100644 --- a/reflectapi-demo/clients/python/tests/integration/test_client_server_e2e.py +++ b/reflectapi-demo/clients/python/tests/integration/test_client_server_e2e.py @@ -30,7 +30,7 @@ MyapiProtoPetsListRequest as PetsListRequest, MyapiProtoHeaders as Headers, ) -from reflectapi_runtime import ReflectapiOption, ApiError +from reflectapi_runtime import ApiError from tests.model_helpers import calm_behavior, other_behavior, root_value @@ -138,7 +138,7 @@ async def test_update_pet_with_kind_change(self, client, auth_headers): request = PetsUpdateRequest( name="existing_pet", # Assume this exists or will fail gracefully kind=new_kind, - age=ReflectapiOption(4), + age=4, ) try: diff --git a/reflectapi-demo/clients/python/tests/runtime_integration/test_reflectapi_option.py b/reflectapi-demo/clients/python/tests/runtime_integration/test_reflectapi_option.py deleted file mode 100644 index c4878c0b..00000000 --- a/reflectapi-demo/clients/python/tests/runtime_integration/test_reflectapi_option.py +++ /dev/null @@ -1,233 +0,0 @@ -"""Test ReflectapiOption functionality comprehensively.""" - -import pytest -import json -from pydantic import ValidationError - -from tests.package_imports import ( - MyapiProtoPetsUpdateRequest as PetsUpdateRequest, - MyapiModelBehavior as Behavior, - MyapiModelBehaviorAggressiveVariant as BehaviorAggressive, - MyapiModelBehaviorOtherVariant as BehaviorOther, -) -from reflectapi_runtime import ReflectapiOption, Undefined - -# For externally tagged enums, unit variants are just string literals -BehaviorCalm = "Calm" - - -class TestReflectapiOptionBasics: - """Test basic ReflectapiOption functionality.""" - - def test_undefined_creation(self): - """Test creating ReflectapiOption with Undefined.""" - option = ReflectapiOption(Undefined) - assert option._value is Undefined - assert option.is_undefined - assert not option.is_none - assert not option.is_some - - def test_none_creation(self): - """Test creating ReflectapiOption with None.""" - option = ReflectapiOption(None) - assert option._value is None - assert not option.is_undefined - assert option.is_none - assert not option.is_some - - def test_some_creation(self): - """Test creating ReflectapiOption with value.""" - option = ReflectapiOption(42) - assert option._value == 42 - assert not option.is_undefined - assert not option.is_none - assert option.is_some - - def test_default_is_undefined(self): - """Test default ReflectapiOption is undefined.""" - option = ReflectapiOption() - assert option.is_undefined - - -class TestReflectapiOptionMethods: - """Test ReflectapiOption utility methods.""" - - def test_unwrap_some(self): - """Test unwrapping a Some value.""" - option = ReflectapiOption(42) - assert option.unwrap() == 42 - - def test_unwrap_undefined_raises(self): - """Test unwrapping Undefined raises ValueError.""" - option = ReflectapiOption(Undefined) - with pytest.raises(ValueError, match="Cannot unwrap undefined option"): - option.unwrap() - - def test_unwrap_none_raises(self): - """Test unwrapping None raises ValueError.""" - option = ReflectapiOption(None) - with pytest.raises(ValueError, match="Cannot unwrap None option"): - option.unwrap() - - def test_unwrap_or_with_some(self): - """Test unwrap_or with Some value.""" - option = ReflectapiOption(42) - assert option.unwrap_or(0) == 42 - - def test_unwrap_or_with_undefined(self): - """Test unwrap_or with Undefined.""" - option = ReflectapiOption(Undefined) - assert option.unwrap_or(0) == 0 - - def test_unwrap_or_with_none(self): - """Test unwrap_or with None.""" - option = ReflectapiOption(None) - assert option.unwrap_or(0) == 0 - - def test_map_some(self): - """Test mapping over Some value.""" - option = ReflectapiOption(42) - mapped = option.map(lambda x: x * 2) - assert mapped.unwrap() == 84 - - def test_map_undefined(self): - """Test mapping over Undefined.""" - option = ReflectapiOption(Undefined) - mapped = option.map(lambda x: x * 2) - assert mapped.is_undefined - - def test_map_none(self): - """Test mapping over None.""" - option = ReflectapiOption(None) - mapped = option.map(lambda x: x * 2) - assert mapped.is_none - - -class TestReflectapiOptionSerialization: - """Test ReflectapiOption serialization behavior.""" - - def test_option_serialization_basic(self): - """Test basic ReflectapiOption serialization.""" - # Test with explicit ReflectapiOption values - option_some = ReflectapiOption(42) - option_none = ReflectapiOption(None) - option_undefined = ReflectapiOption(Undefined) - - # Test individual option serialization via their values - assert option_some._value == 42 - assert option_none._value is None - assert option_undefined._value is Undefined - - -class TestReflectapiOptionDeserialization: - """Test ReflectapiOption deserialization from data.""" - - def test_option_creation_from_values(self): - """Test creating options from different values.""" - # Test direct creation with values - option_int = ReflectapiOption(42) - option_list = ReflectapiOption([BehaviorCalm]) - - assert option_int.unwrap() == 42 - assert len(option_list.unwrap()) == 1 - assert option_list.unwrap()[0] == "Calm" - - -class TestReflectapiOptionEquality: - """Test ReflectapiOption equality comparisons.""" - - def test_undefined_equality(self): - """Test Undefined options are equal.""" - opt1 = ReflectapiOption(Undefined) - opt2 = ReflectapiOption(Undefined) - assert opt1 == opt2 - - def test_none_equality(self): - """Test None options are equal.""" - opt1 = ReflectapiOption(None) - opt2 = ReflectapiOption(None) - assert opt1 == opt2 - - def test_some_equality(self): - """Test Some options with same value are equal.""" - opt1 = ReflectapiOption(42) - opt2 = ReflectapiOption(42) - assert opt1 == opt2 - - def test_different_values_not_equal(self): - """Test options with different values are not equal.""" - opt1 = ReflectapiOption(42) - opt2 = ReflectapiOption(24) - assert opt1 != opt2 - - def test_different_states_not_equal(self): - """Test options in different states are not equal.""" - opt1 = ReflectapiOption(Undefined) - opt2 = ReflectapiOption(None) - opt3 = ReflectapiOption(42) - - assert opt1 != opt2 - assert opt2 != opt3 - assert opt1 != opt3 - - -class TestReflectapiOptionRepr: - """Test ReflectapiOption string representations.""" - - def test_undefined_repr(self): - """Test Undefined repr.""" - option = ReflectapiOption(Undefined) - assert "Undefined" in repr(option) - - def test_none_repr(self): - """Test None repr.""" - option = ReflectapiOption(None) - assert "None" in repr(option) - - def test_some_repr(self): - """Test Some repr.""" - option = ReflectapiOption(42) - assert "42" in repr(option) - - -class TestReflectapiOptionComplexTypes: - """Test ReflectapiOption with complex types.""" - - def test_list_option(self): - """Test ReflectapiOption with list value.""" - behaviors = [BehaviorCalm, {"Aggressive": [5.0, "test"]}] - option = ReflectapiOption(behaviors) - - assert option.is_some - unwrapped = option.unwrap() - assert len(unwrapped) == 2 - assert unwrapped[0] == "Calm" - assert unwrapped[1] == {"Aggressive": [5.0, "test"]} - - def test_empty_list_option(self): - """Test ReflectapiOption with empty list.""" - option = ReflectapiOption([]) - - assert option.is_some - assert option.unwrap() == [] - - def test_nested_serialization(self): - """Test complex nested serialization.""" - behaviors = [ - BehaviorCalm, - {"Other": {"description": "Custom", "notes": "Test"}}, - ] - request = PetsUpdateRequest( - name="complex_test", - age=ReflectapiOption(Undefined), - behaviors=ReflectapiOption(behaviors), - ) - - # Test that the request has the expected values - assert request.behaviors.is_some - unwrapped_behaviors = request.behaviors.unwrap() - assert len(unwrapped_behaviors) == 2 - assert unwrapped_behaviors[0] == "Calm" - assert unwrapped_behaviors[1] == { - "Other": {"description": "Custom", "notes": "Test"} - } diff --git a/reflectapi-demo/clients/python/uv.lock b/reflectapi-demo/clients/python/uv.lock index 4b7b9c3d..fcede39c 100644 --- a/reflectapi-demo/clients/python/uv.lock +++ b/reflectapi-demo/clients/python/uv.lock @@ -266,7 +266,7 @@ dev = [ [[package]] name = "reflectapi-runtime" -version = "0.17.3" +version = "0.17.6" source = { editable = "../../../reflectapi-python-runtime" } dependencies = [ { name = "httpx" }, diff --git a/reflectapi-python-runtime/README.md b/reflectapi-python-runtime/README.md index e9360123..cfb08d26 100644 --- a/reflectapi-python-runtime/README.md +++ b/reflectapi-python-runtime/README.md @@ -15,8 +15,9 @@ directly. - `ApplicationError`, `NetworkError`, `TimeoutError`, `ValidationError` — exceptions raised by the generated methods on non-2xx, transport, and validation failures respectively. -- `ReflectapiOption` — three-state Option used by the generated models - (`some` / `none` / `undefined`) so absent and explicit-null can round-trip. +- `ReflectapiPartialModel` — base class for generated models with three-state + fields (value / explicit-null / absent) so absent and explicit-null can + round-trip, tracked via Pydantic's `model_fields_set`. - Authentication helpers (`BearerTokenAuth`, `APIKeyAuth`, `BasicAuth`, `OAuth2ClientCredentialsAuth`, `OAuth2AuthorizationCodeAuth`). - Middleware, batching, and testing utilities (`MockClient`, diff --git a/reflectapi-python-runtime/src/reflectapi_runtime/client.py b/reflectapi-python-runtime/src/reflectapi_runtime/client.py index efb448b0..3638a228 100644 --- a/reflectapi-python-runtime/src/reflectapi_runtime/client.py +++ b/reflectapi-python-runtime/src/reflectapi_runtime/client.py @@ -2,6 +2,7 @@ from __future__ import annotations +import contextlib import datetime import json import time @@ -50,6 +51,119 @@ def _json_serializer(obj: Any) -> Any: # Note: AsyncAuthWrapper removed - AuthHandler now inherits from httpx.Auth directly +def _synthesize_raw_response( + status: int, headers: Any, body: bytes | str | None +) -> httpx.Response: + """Build a stand-in ``httpx.Response`` for ``TransportMetadata.raw_response``. + + Used only when a custom transport returns a structural :class:`Response` + without a wire-level ``raw`` object to surface. Response parsing never + goes through this synthetic object — it exists purely so + ``raw_response`` keeps its ``httpx.Response`` shape for callers. + + The structural body is already decoded (``httpx.Response.content`` + decompresses on read), but the headers may still advertise + ``Content-Encoding``. Passing both back into + ``httpx.Response(content=...)`` would make httpx decompress the decoded + bytes a second time and raise ``DecodingError``, so the compression + headers are stripped. ``Content-Length`` describes the wire body, which + the structural body (decoded, possibly middleware-rewritten) need not + match, so it is always dropped. + """ + sanitized_headers = httpx.Headers(headers) + if "content-encoding" in sanitized_headers: + del sanitized_headers["content-encoding"] + if "content-length" in sanitized_headers: + del sanitized_headers["content-length"] + return httpx.Response( + status_code=status, + headers=sanitized_headers, + content=body, + ) + + +def _parse_json_body(body: bytes | str | None) -> Any: + """Parse a response body as JSON, raising ``ValidationError`` on failure.""" + try: + return json.loads(body if body is not None else b"") + except Exception as e: + raise ValidationError( + f"Failed to parse JSON response: {e}", + cause=e, + ) + + +def _raise_for_error_status( + status: int, + body: bytes | str | None, + metadata: TransportMetadata, + error_model: type | None = None, +) -> None: + """Raise ``ApplicationError`` for HTTP error responses (4xx, 5xx). + + If ``error_model`` is provided, attempts to deserialize the error body + into a typed Pydantic model before raising. + """ + if status < 400: + return + + error_data = None + typed_error = None + with contextlib.suppress(Exception): + error_data = json.loads(body) + + # Try typed error deserialization; fall back to raw error_data + if error_model is not None and error_data is not None: + with contextlib.suppress(Exception): + typed_error = TypeAdapter(error_model).validate_python(error_data) + + message = f"API error {status}: {httpx.codes.get_reason_phrase(status)}" + if error_data: + message += f" - {error_data}" + + raise ApplicationError( + message, + metadata=metadata, + error_data=error_data, + typed_error=typed_error, + ) + + +def _validate_body( + body: bytes | str | None, + response_model: type[T] | type[Any] | str | _NoValidation, + metadata: TransportMetadata, +) -> ApiResponse[T] | ApiResponse[dict[str, Any]]: + """Validate a response body using Pydantic via TypeAdapter. + + TypeAdapter handles all types: plain BaseModel, Generic types + (list[Model], dict[str, Model]), Union types, and primitives. + Uses validate_json for Pydantic's fast Rust-based JSON parsing when raw + bytes are available. + """ + # Handle special cases where no validation is needed + if ( + response_model == "Any" + or response_model is NO_VALIDATION + or response_model is Any + ): + return ApiResponse(_parse_json_body(body), metadata) + + try: + ta = TypeAdapter(response_model) + if isinstance(body, (bytes, bytearray, str)): + validated_data = ta.validate_json(body) + else: + validated_data = ta.validate_python(_parse_json_body(body)) + return ApiResponse(validated_data, metadata) + except PydanticValidationError as e: + raise ValidationError( + f"Response validation failed: {e}", + validation_errors=e.errors(), + cause=e, + ) + + class ClientBase(ABC): """Base class for synchronous ReflectAPI clients.""" @@ -389,85 +503,6 @@ def terminal(req: Request) -> Response: return terminal - def _handle_error_response( - self, - response: httpx.Response, - metadata: TransportMetadata, - error_model: type | None = None, - ) -> None: - """Handle HTTP error responses (4xx, 5xx). - - If error_model is provided, attempts to deserialize the error body - into a typed Pydantic model before raising ApplicationError. - """ - if response.status_code >= 400: - error_data = None - typed_error = None - try: - error_data = response.json() - except Exception: - pass - - # Try typed error deserialization - if error_model is not None and error_data is not None: - try: - ta = TypeAdapter(error_model) - typed_error = ta.validate_python(error_data) - except Exception: - pass # Fall back to raw error_data - - raise ApplicationError.from_response( - response, metadata, error_data, typed_error=typed_error - ) - - def _parse_json_response(self, response: httpx.Response) -> dict[str, Any]: - """Parse JSON response with error handling.""" - try: - return response.json() - except Exception as e: - raise ValidationError( - f"Failed to parse JSON response: {e}", - cause=e, - ) - - def _validate_response_model( - self, - response: httpx.Response, - response_model: type[T] | type[Any] | str | _NoValidation, - metadata: TransportMetadata, - ) -> ApiResponse[T] | ApiResponse[dict[str, Any]]: - """Validate response using Pydantic model via TypeAdapter. - - TypeAdapter handles all types: plain BaseModel, Generic types - (list[Model], dict[str, Model]), Union types, and primitives. - Uses validate_json for performance when raw bytes are available. - """ - # Handle special cases where no validation is needed - if response_model == "Any" or response_model is NO_VALIDATION: - json_response = self._parse_json_response(response) - return ApiResponse(json_response, metadata) - - if response_model is Any: - json_response = self._parse_json_response(response) - return ApiResponse(json_response, metadata) - - try: - ta = TypeAdapter(response_model) - # Prefer validate_json for Pydantic's fast Rust-based JSON parsing - content = response.content - if isinstance(content, (bytes, bytearray)): - validated_data = ta.validate_json(content) - else: - json_response = self._parse_json_response(response) - validated_data = ta.validate_python(json_response) - return ApiResponse(validated_data, metadata) - except PydanticValidationError as e: - raise ValidationError( - f"Response validation failed: {e}", - validation_errors=e.errors(), - cause=e, - ) - def _make_request( self, @@ -501,33 +536,33 @@ def _make_request( # response so any middleware transforms apply; `raw` is used # only as the metadata sidecar (preserves `.request` / # `.extensions` / `.history` from the real wire response when - # available, fallback to the synthetic for custom transports). - parsed_response = httpx.Response( - status_code=client_response.status, - headers=client_response.headers, - content=client_response.body, - ) + # available, fallback to a synthetic for custom transports). metadata = TransportMetadata( status_code=client_response.status, headers=client_response.headers, timing=time.time() - start_time, - raw_response=client_response.raw or parsed_response, + raw_response=client_response.raw + or _synthesize_raw_response( + client_response.status, + client_response.headers, + client_response.body, + ), ) # Handle error responses - self._handle_error_response( - parsed_response, metadata, error_model=error_model + _raise_for_error_status( + client_response.status, + client_response.body, + metadata, + error_model=error_model, ) # Validate and return response if response_model is not None: - return self._validate_response_model( - parsed_response, response_model, metadata - ) + return _validate_body(client_response.body, response_model, metadata) else: - # No response_model provided - parse JSON into dict - json_response = self._parse_json_response(parsed_response) - return ApiResponse(json_response, metadata) + # No response_model provided - parse JSON as-is + return ApiResponse(_parse_json_body(client_response.body), metadata) except httpx.TimeoutException as e: raise TimeoutError.from_httpx_timeout(e) @@ -581,7 +616,12 @@ def _make_sse_request( if response.status_code >= 400: # read_full body so error parsing can see it response.read() - self._handle_error_response(response, metadata, error_model=error_model) + _raise_for_error_status( + response.status_code, + response.content, + metadata, + error_model=error_model, + ) adapter: TypeAdapter[Any] | None = None if ( @@ -947,73 +987,6 @@ async def terminal(req: Request) -> Response: return terminal - def _handle_error_response( - self, - response: httpx.Response, - metadata: TransportMetadata, - error_model: type | None = None, - ) -> None: - """Handle HTTP error responses (4xx, 5xx).""" - if response.status_code >= 400: - error_data = None - typed_error = None - try: - error_data = response.json() - except Exception: - pass - - if error_model is not None and error_data is not None: - try: - ta = TypeAdapter(error_model) - typed_error = ta.validate_python(error_data) - except Exception: - pass - - raise ApplicationError.from_response( - response, metadata, error_data, typed_error=typed_error - ) - - def _parse_json_response(self, response: httpx.Response) -> dict[str, Any]: - """Parse JSON response with error handling.""" - try: - return response.json() - except Exception as e: - raise ValidationError( - f"Failed to parse JSON response: {e}", - cause=e, - ) - - def _validate_response_model( - self, - response: httpx.Response, - response_model: type[T] | type[Any] | str | _NoValidation, - metadata: TransportMetadata, - ) -> ApiResponse[T] | ApiResponse[dict[str, Any]]: - """Validate response using Pydantic model via TypeAdapter.""" - if response_model == "Any" or response_model is NO_VALIDATION: - json_response = self._parse_json_response(response) - return ApiResponse(json_response, metadata) - - if response_model is Any: - json_response = self._parse_json_response(response) - return ApiResponse(json_response, metadata) - - try: - ta = TypeAdapter(response_model) - content = response.content - if isinstance(content, (bytes, bytearray)): - validated_data = ta.validate_json(content) - else: - json_response = self._parse_json_response(response) - validated_data = ta.validate_python(json_response) - return ApiResponse(validated_data, metadata) - except PydanticValidationError as e: - raise ValidationError( - f"Response validation failed: {e}", - validation_errors=e.errors(), - cause=e, - ) - async def _make_request( self, @@ -1047,32 +1020,32 @@ async def _make_request( # response so any middleware transforms apply; `raw` is used # only as the metadata sidecar (preserves `.request` / # `.extensions` / `.history` from the real wire response when - # available, fallback to the synthetic for custom transports). - parsed_response = httpx.Response( - status_code=client_response.status, - headers=client_response.headers, - content=client_response.body, - ) + # available, fallback to a synthetic for custom transports). metadata = TransportMetadata( status_code=client_response.status, headers=client_response.headers, timing=time.time() - start_time, - raw_response=client_response.raw or parsed_response, + raw_response=client_response.raw + or _synthesize_raw_response( + client_response.status, + client_response.headers, + client_response.body, + ), ) # Handle error responses - self._handle_error_response( - parsed_response, metadata, error_model=error_model + _raise_for_error_status( + client_response.status, + client_response.body, + metadata, + error_model=error_model, ) # Validate and return response if response_model is not None: - return self._validate_response_model( - parsed_response, response_model, metadata - ) + return _validate_body(client_response.body, response_model, metadata) else: - json_response = self._parse_json_response(parsed_response) - return ApiResponse(json_response, metadata) + return ApiResponse(_parse_json_body(client_response.body), metadata) except httpx.TimeoutException as e: raise TimeoutError.from_httpx_timeout(e) @@ -1125,7 +1098,12 @@ async def _make_sse_request( metadata = TransportMetadata.from_response(response, start_time) if response.status_code >= 400: await response.aread() - self._handle_error_response(response, metadata, error_model=error_model) + _raise_for_error_status( + response.status_code, + response.content, + metadata, + error_model=error_model, + ) adapter: TypeAdapter[Any] | None = None if ( diff --git a/reflectapi-python-runtime/src/reflectapi_runtime/transport.py b/reflectapi-python-runtime/src/reflectapi_runtime/transport.py index e89ea7e8..dd683834 100644 --- a/reflectapi-python-runtime/src/reflectapi_runtime/transport.py +++ b/reflectapi-python-runtime/src/reflectapi_runtime/transport.py @@ -26,7 +26,16 @@ class Request: @dataclass(frozen=True) class Response: - """Transport response returned by custom ReflectAPI Python clients.""" + """Transport response returned by custom ReflectAPI Python clients. + + ``body`` must be the *decoded* entity bytes — i.e. after any + ``Content-Encoding`` (gzip, br, ...) has been reversed. Adapters that + read ``httpx.Response.content`` get this for free; adapters over rawer + transports must decompress before constructing the DTO. ``headers`` may + still carry the original wire headers (including ``Content-Encoding``); + the runtime treats them as informational and never re-applies them to + ``body``. + """ status: int # Permissive headers type so adapters wrapping httpx.Headers (which is diff --git a/reflectapi-python-runtime/tests/test_client.py b/reflectapi-python-runtime/tests/test_client.py index 077fbf85..87dfc4d5 100644 --- a/reflectapi-python-runtime/tests/test_client.py +++ b/reflectapi-python-runtime/tests/test_client.py @@ -907,3 +907,269 @@ def handler(request: httpx.Request) -> httpx.Response: assert result.value.name == "healed" assert result.metadata.status_code == 200 + + +class TestCompressedResponseNotDoubleDecoded: + """A transport that has already decompressed the body (httpx does this + when reading ``.content``) may still surface ``Content-Encoding: gzip`` + in the headers. Rebuilding an ``httpx.Response`` from that pair must not + attempt a second decompression of the already-decoded bytes. + + Regression test: previously this raised + ``NetworkError: Error -3 while decompressing data: incorrect header check``. + """ + + @staticmethod + def _gzip_handler(request: httpx.Request) -> httpx.Response: + import gzip + + return httpx.Response( + 200, + headers={ + "content-type": "application/json", + "content-encoding": "gzip", + }, + content=gzip.compress(b'{"name":"zipped","age":3}'), + ) + + def test_sync_gzip_response(self): + with httpx.Client(transport=httpx.MockTransport(self._gzip_handler)) as raw: + client = ClientBase("http://example.com", client=raw) + result = client._make_request("/test", response_model=SampleModel) + + assert result.value.name == "zipped" + # Metadata still reflects the original wire headers. + assert result.metadata.headers.get("content-encoding") == "gzip" + + @pytest.mark.asyncio + async def test_async_gzip_response(self): + async with httpx.AsyncClient( + transport=httpx.MockTransport(self._gzip_handler) + ) as raw: + client = AsyncClientBase("http://example.com", client=raw) + result = await client._make_request("/test", response_model=SampleModel) + + assert result.value.name == "zipped" + assert result.metadata.headers.get("content-encoding") == "gzip" + + def test_sync_custom_transport_decoded_body_with_encoding_header(self): + """A custom structural transport returning a decoded body while + keeping the compression header must also parse cleanly.""" + + class _DecodedButLabelled: + def request(self, request: Request) -> Response: + return Response( + status=200, + headers=httpx.Headers( + { + "content-type": "application/json", + "content-encoding": "gzip", + "content-length": "17", + } + ), + body=b'{"name":"raw","age":9}', + ) + + client = ClientBase("http://example.com", client=_DecodedButLabelled()) + result = client._make_request("/test", response_model=SampleModel) + assert result.value.name == "raw" + + +class TestParsingBypassesSyntheticHttpxResponse: + """Response parsing operates on the structural body directly — it never + round-trips through a rebuilt ``httpx.Response``, so stale wire headers + (compression, content-length) can't corrupt parsing on any path. + """ + + @staticmethod + def _gzip_error_handler(request: httpx.Request) -> httpx.Response: + import gzip + + return httpx.Response( + 404, + headers={ + "content-type": "application/json", + "content-encoding": "gzip", + }, + content=gzip.compress(b'{"reason":"missing"}'), + ) + + def test_sync_compressed_error_response_parses_error_body(self): + with httpx.Client( + transport=httpx.MockTransport(self._gzip_error_handler) + ) as raw: + client = ClientBase("http://example.com", client=raw) + with pytest.raises(ApplicationError) as exc_info: + client._make_request("/test", response_model=SampleModel) + + assert exc_info.value.status_code == 404 + assert exc_info.value.error_data == {"reason": "missing"} + assert "API error 404: Not Found" in str(exc_info.value) + + @pytest.mark.asyncio + async def test_async_compressed_error_response_parses_error_body(self): + async with httpx.AsyncClient( + transport=httpx.MockTransport(self._gzip_error_handler) + ) as raw: + client = AsyncClientBase("http://example.com", client=raw) + with pytest.raises(ApplicationError) as exc_info: + await client._make_request("/test", response_model=SampleModel) + + assert exc_info.value.status_code == 404 + assert exc_info.value.error_data == {"reason": "missing"} + + def test_sync_typed_error_from_compressed_response(self): + class ErrorModel(BaseModel): + reason: str + + with httpx.Client( + transport=httpx.MockTransport(self._gzip_error_handler) + ) as raw: + client = ClientBase("http://example.com", client=raw) + with pytest.raises(ApplicationError) as exc_info: + client._make_request( + "/test", response_model=SampleModel, error_model=ErrorModel + ) + + assert exc_info.value.typed_error == ErrorModel(reason="missing") + + def test_middleware_rewritten_body_ignores_stale_wire_headers(self): + """Middleware can replace the body entirely; the original wire + headers (content-length, content-encoding) must not be applied to + the new body during parsing.""" + import gzip + from dataclasses import replace + + from reflectapi_runtime.middleware import SyncMiddleware + + class _Rewrite(SyncMiddleware): + def handle(self, request, next_call): + resp = next_call(request) + return replace(resp, body=b'{"name":"rewritten","age":7}') + + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response( + 200, + headers={ + "content-type": "application/json", + "content-encoding": "gzip", + }, + content=gzip.compress(b'{"name":"wire","age":1}'), + ) + + with httpx.Client(transport=httpx.MockTransport(handler)) as raw: + client = ClientBase( + "http://example.com", client=raw, middleware=[_Rewrite()] + ) + result = client._make_request("/test", response_model=SampleModel) + + assert result.value.name == "rewritten" + + def test_no_response_model_parses_json_directly(self): + import gzip + + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response( + 200, + headers={ + "content-type": "application/json", + "content-encoding": "gzip", + }, + content=gzip.compress(b'{"free": "form"}'), + ) + + with httpx.Client(transport=httpx.MockTransport(handler)) as raw: + client = ClientBase("http://example.com", client=raw) + result = client._make_request("/test") + + assert result.value == {"free": "form"} + + def test_invalid_json_error_body_still_raises_application_error(self): + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(500, content=b"not json at all") + + with httpx.Client(transport=httpx.MockTransport(handler)) as raw: + client = ClientBase("http://example.com", client=raw) + with pytest.raises(ApplicationError) as exc_info: + client._make_request("/test", response_model=SampleModel) + + assert exc_info.value.status_code == 500 + assert exc_info.value.error_data is None + assert "API error 500: Internal Server Error" in str(exc_info.value) + + def test_invalid_json_success_body_raises_validation_error(self): + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, content=b"not json") + + with httpx.Client(transport=httpx.MockTransport(handler)) as raw: + client = ClientBase("http://example.com", client=raw) + with pytest.raises(ValidationError): + client._make_request("/test") + + def test_synthetic_raw_response_fallback_is_sanitized(self): + """Custom transports without a wire-level `raw` get a synthetic + raw_response whose compression headers are stripped so it is + readable, while metadata.headers keeps the original wire view.""" + + class _DecodedButLabelled: + def request(self, request: Request) -> Response: + return Response( + status=200, + headers=httpx.Headers( + { + "content-type": "application/json", + "content-encoding": "gzip", + "content-length": "17", + } + ), + body=b'{"name":"raw","age":9}', + ) + + client = ClientBase("http://example.com", client=_DecodedButLabelled()) + result = client._make_request("/test", response_model=SampleModel) + + assert result.value.name == "raw" + assert result.metadata.headers.get("content-encoding") == "gzip" + raw = result.metadata.raw_response + assert isinstance(raw, httpx.Response) + assert "content-encoding" not in raw.headers + assert raw.json() == {"name": "raw", "age": 9} + + def test_wire_raw_response_still_preferred_over_synthetic(self): + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, content=b'{"name":"x","age":1}') + + with httpx.Client(transport=httpx.MockTransport(handler)) as raw: + client = ClientBase("http://example.com", client=raw) + result = client._make_request("/test", response_model=SampleModel) + + # The real wire response carries .request; a synthetic would not. + assert result.metadata.raw_response.request is not None + + def test_synthetic_raw_response_recomputes_stale_content_length(self): + """Even without Content-Encoding, the wire Content-Length may not + describe the structural body (e.g. after middleware rewrites it). + The stale value is dropped and httpx re-derives it from the actual + body when constructing the synthetic.""" + + class _StaleLength: + def request(self, request: Request) -> Response: + return Response( + status=200, + headers=httpx.Headers( + { + "content-type": "application/json", + "content-length": "9999", + } + ), + body=b'{"name":"raw","age":9}', + ) + + client = ClientBase("http://example.com", client=_StaleLength()) + result = client._make_request("/test", response_model=SampleModel) + + body = b'{"name":"raw","age":9}' + assert result.metadata.raw_response.headers.get("content-length") == str( + len(body) + ) + assert result.metadata.headers.get("content-length") == "9999"