From 1851696ad9ed8c6acb175cd52655febca1e7506d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 22:00:20 +0000 Subject: [PATCH] fix: Replace NULL sentinels in form data and tolerate malformed validation errors Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Y1RzepycXEYA3LStfjt8cY --- seam/client.py | 6 +++++ seam/exceptions.py | 17 ++++++++++-- test/conftest.py | 13 ++++++++- test/null_data_test.py | 60 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 93 insertions(+), 3 deletions(-) create mode 100644 test/null_data_test.py diff --git a/seam/client.py b/seam/client.py index 0f9e7fe0..41789f1a 100644 --- a/seam/client.py +++ b/seam/client.py @@ -158,6 +158,9 @@ def request(self, method, url, *args, **kwargs) -> Any: if "json" in kwargs: kwargs["json"] = replace_null(kwargs["json"]) + if isinstance(kwargs.get("data"), Mapping): + kwargs["data"] = replace_null(kwargs["data"]) + response = super().request(method, url, *args, **kwargs) return self._handle_response(response) @@ -219,6 +222,9 @@ async def request(self, method, url, *args, **kwargs) -> Any: if "json" in kwargs: kwargs["json"] = replace_null(kwargs["json"]) + if isinstance(kwargs.get("data"), Mapping): + kwargs["data"] = replace_null(kwargs["data"]) + response = await super().request(method, url, *args, **kwargs) return self._handle_response(response) diff --git a/seam/exceptions.py b/seam/exceptions.py index ef9fbeb1..f4b46ed2 100644 --- a/seam/exceptions.py +++ b/seam/exceptions.py @@ -95,7 +95,13 @@ def __init__( super().__init__(error, status_code, request_id) self.code = "invalid_input" - self._validation_errors = error.get("validation_errors") or {} + validation_errors = error.get("validation_errors") + # The envelope shape is server-controlled: anything but the expected + # object of objects reads as no validation details rather than + # raising from inside the error accessors. + self._validation_errors = ( + validation_errors if isinstance(validation_errors, dict) else {} + ) @property def validation_errors(self) -> List[SeamValidationError]: @@ -119,7 +125,14 @@ def get_validation_error_messages(self, param_name: str) -> List[str]: :rtype: List[str] """ - return self._validation_errors.get(param_name, {}).get("_errors", []) + messages = self._validation_errors.get(param_name) + + if not isinstance(messages, dict): + return [] + + errors = messages.get("_errors", []) + + return errors if isinstance(errors, list) else [] # Action Attempt diff --git a/test/conftest.py b/test/conftest.py index 5c3c9710..5a5e6ebf 100755 --- a/test/conftest.py +++ b/test/conftest.py @@ -94,7 +94,7 @@ def _handle_request(self): "path": path, "query": query, "headers": {k.lower(): v for k, v in self.headers.items()}, - "body": json.loads(raw_body) if raw_body else None, + "body": parse_body(raw_body), } ) @@ -140,6 +140,17 @@ def log_message(self, *args): thread.join(timeout=5) +def parse_body(raw_body): + if not raw_body: + return None + + try: + return json.loads(raw_body) + except json.JSONDecodeError: + # Form-encoded bodies are recorded as text. + return raw_body.decode() + + @contextmanager def fake_seam_connect(): if not FAKE_SEAM_CONNECT_BIN.exists(): diff --git a/test/null_data_test.py b/test/null_data_test.py new file mode 100644 index 00000000..c5a5fbff --- /dev/null +++ b/test/null_data_test.py @@ -0,0 +1,60 @@ +from seam import NULL, Seam +from seam.exceptions import SeamHttpInvalidInputError + + +def test_null_sentinel_in_form_data_is_replaced(recording_server): + with recording_server([(200, {"ok": True})]) as (endpoint, requests): + seam = Seam.from_api_key("seam_apikey_token", endpoint=endpoint) + + seam.client.post("/example", data={"name": NULL, "kept": "value"}) + + # The sentinel form-encodes as an empty value, never the string NULL. + assert requests[0]["body"] == "name=&kept=value" + + +def make_invalid_input_error(validation_errors): + return SeamHttpInvalidInputError( + { + "type": "invalid_input", + "message": "Invalid input", + "validation_errors": validation_errors, + }, + 400, + "request-id", + ) + + +def test_validation_errors_tolerates_a_list_envelope(): + error = make_invalid_input_error(["not", "a", "dict"]) + + assert error.validation_errors == [] + assert error.get_validation_error_messages("device_ids") == [] + + +def test_validation_errors_tolerates_a_string_envelope(): + error = make_invalid_input_error("bad input") + + assert error.validation_errors == [] + assert error.get_validation_error_messages("device_ids") == [] + + +def test_validation_errors_tolerates_a_non_dict_parameter_value(): + error = make_invalid_input_error( + { + "device_ids": ["bad"], + "name": {"_errors": ["Required"]}, + } + ) + + assert error.get_validation_error_messages("device_ids") == [] + assert error.get_validation_error_messages("name") == ["Required"] + + validation_errors = error.validation_errors + assert len(validation_errors) == 2 + assert {e.parameter_name for e in validation_errors} == {"device_ids", "name"} + + +def test_validation_errors_tolerates_non_list_errors(): + error = make_invalid_input_error({"name": {"_errors": "Required"}}) + + assert error.get_validation_error_messages("name") == []