Skip to content
Merged
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
20 changes: 20 additions & 0 deletions MIGRATION_v2_to_v3.md
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,26 @@ The general renaming rules:
| `MembershipLevel` | `MembershipLevelResponse` | |
| `ThreadedComment` | `ThreadedCommentResponse` | |

## JSON Serialization of Optional Fields

Optional fields in request objects are now omitted from the JSON body when not set, instead of being sent as explicit `null`. Previously, every unset field was serialized as `null`, which caused the backend to zero out existing values on partial updates.

**Before:**
```python
client.update_app(enforce_unique_usernames="no")
# Wire: {"enforce_unique_usernames":"no","webhook_url":null,"multi_tenant_enabled":null,...}
# Backend: sets enforce_unique_usernames="no", but ALSO resets webhook_url="", multi_tenant_enabled=false, etc.
```

**After:**
```python
client.update_app(enforce_unique_usernames="no")
# Wire: {"enforce_unique_usernames":"no"}
# Backend: sets enforce_unique_usernames="no", all other fields preserved
```

List and dict fields are still serialized when set (including as empty `[]`/`{}`), so you can continue to send an empty list to clear a list field. Unset collection fields (`None`) are now also omitted.

## Getting Help

- [Stream documentation](https://getstream.io/docs/)
Expand Down
14 changes: 14 additions & 0 deletions getstream/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,16 @@
import ijson


def _strip_none(obj):
"""Recursively remove None values from dicts so unset optional fields
are omitted from the JSON body instead of being sent as null."""
if isinstance(obj, dict):
return {k: _strip_none(v) for k, v in obj.items() if v is not None}
if isinstance(obj, list):
return [_strip_none(item) for item in obj]
return obj


def build_path(path: str, path_params: Optional[Dict[str, Any]]) -> str:
if path_params is None:
return path
Expand Down Expand Up @@ -169,6 +179,8 @@ def _request_sync(
data_type: Optional[Type[T]] = None,
):
kwargs = kwargs or {}
if "json" in kwargs and kwargs["json"] is not None:
kwargs["json"] = _strip_none(kwargs["json"])
url_path, url_full, endpoint, attrs = self._prepare_request(
method, path, query_params, kwargs
)
Expand Down Expand Up @@ -348,6 +360,8 @@ async def _request_async(
data_type: Optional[Type[T]] = None,
):
kwargs = kwargs or {}
if "json" in kwargs and kwargs["json"] is not None:
kwargs["json"] = _strip_none(kwargs["json"])
query_params = query_params or {}
url_path, url_full, endpoint, attrs = self._prepare_request(
method, path, query_params, kwargs
Expand Down
26 changes: 26 additions & 0 deletions tests/test_decoding.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import pytest

from getstream.models import GetCallResponse, OwnCapability, OwnCapabilityType
from getstream.base import _strip_none
from getstream.utils import (
datetime_from_unix_ns,
encode_datetime,
Expand Down Expand Up @@ -337,6 +338,31 @@ def test_call_session_response_from_dict_with_none():
assert call_session.ended_at is None


def test_strip_none_flat_dict():
assert _strip_none({"a": 1, "b": None, "c": "x"}) == {"a": 1, "c": "x"}


def test_strip_none_nested_dict():
payload = {"a": 1, "b": None, "nested": {"c": None, "d": 2}}
assert _strip_none(payload) == {"a": 1, "nested": {"d": 2}}


def test_strip_none_preserves_empty_collections():
payload = {"tags": [], "meta": {}, "name": None}
assert _strip_none(payload) == {"tags": [], "meta": {}}


def test_strip_none_preserves_list_elements():
payload = {"ids": [1, None, 3], "data": [{"a": None, "b": 2}]}
assert _strip_none(payload) == {"ids": [1, None, 3], "data": [{"b": 2}]}


def test_strip_none_passthrough_scalars():
assert _strip_none(42) == 42
assert _strip_none("hello") == "hello"
assert _strip_none(True) is True


@pytest.mark.skip("fixture is not longer valid, skip for now")
def test_get_call_response_from_dict():
# Read the fixture file
Expand Down