From 29a1b91293d08fc276c500d351dc81312d8a8ef8 Mon Sep 17 00:00:00 2001 From: Yun Wang Date: Thu, 19 Mar 2026 16:56:49 +0100 Subject: [PATCH 1/2] fix: ignore null fields --- MIGRATION_v2_to_v3.md | 20 ++++++++++++++++++++ getstream/base.py | 14 ++++++++++++++ uv.lock | 2 +- 3 files changed, 35 insertions(+), 1 deletion(-) diff --git a/MIGRATION_v2_to_v3.md b/MIGRATION_v2_to_v3.md index 5f7487a3..512f530e 100644 --- a/MIGRATION_v2_to_v3.md +++ b/MIGRATION_v2_to_v3.md @@ -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/) diff --git a/getstream/base.py b/getstream/base.py index e97bd7ba..dd3220f7 100644 --- a/getstream/base.py +++ b/getstream/base.py @@ -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 @@ -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 ) @@ -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 diff --git a/uv.lock b/uv.lock index 08c32e71..3bc04b64 100644 --- a/uv.lock +++ b/uv.lock @@ -955,7 +955,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "aiohttp", marker = "extra == 'webrtc'", specifier = ">=3.13.2,<4" }, - { name = "aiortc", marker = "extra == 'webrtc'", specifier = ">=1.14.0,<2" }, + { name = "aiortc", marker = "extra == 'webrtc'", specifier = ">=1.14.0,<1.15.0" }, { name = "av", marker = "extra == 'webrtc'", specifier = ">=14.2.0,<17" }, { name = "dataclasses-json", specifier = ">=0.6.0,<0.7" }, { name = "httpx", specifier = ">=0.28.1" }, From 97bf1e6cfa6e9e7b756d8e049ff0804356d80921 Mon Sep 17 00:00:00 2001 From: Yun Wang Date: Thu, 19 Mar 2026 17:29:05 +0100 Subject: [PATCH 2/2] test: add unit tests for the stripping behavior --- tests/test_decoding.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/tests/test_decoding.py b/tests/test_decoding.py index 6feeb2e5..1a2c2795 100644 --- a/tests/test_decoding.py +++ b/tests/test_decoding.py @@ -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, @@ -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