Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions seam/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
17 changes: 15 additions & 2 deletions seam/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,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]:
Expand All @@ -123,7 +129,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
Expand Down
13 changes: 12 additions & 1 deletion test/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
}
)

Expand Down Expand Up @@ -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():
Expand Down
60 changes: 60 additions & 0 deletions test/null_data_test.py
Original file line number Diff line number Diff line change
@@ -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") == []
Loading