Skip to content

Commit bc2b6de

Browse files
authored
[CHA-2716] Ignore null fields (#224)
* fix: ignore null fields * test: add unit tests for the stripping behavior
1 parent bc6024c commit bc2b6de

File tree

3 files changed

+60
-0
lines changed

3 files changed

+60
-0
lines changed

MIGRATION_v2_to_v3.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,26 @@ The general renaming rules:
203203
| `MembershipLevel` | `MembershipLevelResponse` | |
204204
| `ThreadedComment` | `ThreadedCommentResponse` | |
205205

206+
## JSON Serialization of Optional Fields
207+
208+
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.
209+
210+
**Before:**
211+
```python
212+
client.update_app(enforce_unique_usernames="no")
213+
# Wire: {"enforce_unique_usernames":"no","webhook_url":null,"multi_tenant_enabled":null,...}
214+
# Backend: sets enforce_unique_usernames="no", but ALSO resets webhook_url="", multi_tenant_enabled=false, etc.
215+
```
216+
217+
**After:**
218+
```python
219+
client.update_app(enforce_unique_usernames="no")
220+
# Wire: {"enforce_unique_usernames":"no"}
221+
# Backend: sets enforce_unique_usernames="no", all other fields preserved
222+
```
223+
224+
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.
225+
206226
## Getting Help
207227

208228
- [Stream documentation](https://getstream.io/docs/)

getstream/base.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,16 @@
2525
import ijson
2626

2727

28+
def _strip_none(obj):
29+
"""Recursively remove None values from dicts so unset optional fields
30+
are omitted from the JSON body instead of being sent as null."""
31+
if isinstance(obj, dict):
32+
return {k: _strip_none(v) for k, v in obj.items() if v is not None}
33+
if isinstance(obj, list):
34+
return [_strip_none(item) for item in obj]
35+
return obj
36+
37+
2838
def build_path(path: str, path_params: Optional[Dict[str, Any]]) -> str:
2939
if path_params is None:
3040
return path
@@ -169,6 +179,8 @@ def _request_sync(
169179
data_type: Optional[Type[T]] = None,
170180
):
171181
kwargs = kwargs or {}
182+
if "json" in kwargs and kwargs["json"] is not None:
183+
kwargs["json"] = _strip_none(kwargs["json"])
172184
url_path, url_full, endpoint, attrs = self._prepare_request(
173185
method, path, query_params, kwargs
174186
)
@@ -348,6 +360,8 @@ async def _request_async(
348360
data_type: Optional[Type[T]] = None,
349361
):
350362
kwargs = kwargs or {}
363+
if "json" in kwargs and kwargs["json"] is not None:
364+
kwargs["json"] = _strip_none(kwargs["json"])
351365
query_params = query_params or {}
352366
url_path, url_full, endpoint, attrs = self._prepare_request(
353367
method, path, query_params, kwargs

tests/test_decoding.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import pytest
88

99
from getstream.models import GetCallResponse, OwnCapability, OwnCapabilityType
10+
from getstream.base import _strip_none
1011
from getstream.utils import (
1112
datetime_from_unix_ns,
1213
encode_datetime,
@@ -337,6 +338,31 @@ def test_call_session_response_from_dict_with_none():
337338
assert call_session.ended_at is None
338339

339340

341+
def test_strip_none_flat_dict():
342+
assert _strip_none({"a": 1, "b": None, "c": "x"}) == {"a": 1, "c": "x"}
343+
344+
345+
def test_strip_none_nested_dict():
346+
payload = {"a": 1, "b": None, "nested": {"c": None, "d": 2}}
347+
assert _strip_none(payload) == {"a": 1, "nested": {"d": 2}}
348+
349+
350+
def test_strip_none_preserves_empty_collections():
351+
payload = {"tags": [], "meta": {}, "name": None}
352+
assert _strip_none(payload) == {"tags": [], "meta": {}}
353+
354+
355+
def test_strip_none_preserves_list_elements():
356+
payload = {"ids": [1, None, 3], "data": [{"a": None, "b": 2}]}
357+
assert _strip_none(payload) == {"ids": [1, None, 3], "data": [{"b": 2}]}
358+
359+
360+
def test_strip_none_passthrough_scalars():
361+
assert _strip_none(42) == 42
362+
assert _strip_none("hello") == "hello"
363+
assert _strip_none(True) is True
364+
365+
340366
@pytest.mark.skip("fixture is not longer valid, skip for now")
341367
def test_get_call_response_from_dict():
342368
# Read the fixture file

0 commit comments

Comments
 (0)