Skip to content

Commit da8f2f6

Browse files
committed
feat: serialize request params with the URL search params standard
The Seam API parses URL search params as complex types, so the SDK has to build the query string itself. Serialize any mapping passed as params and set the result on the url, rather than letting httpx encode the params with its own rules, which represent arrays and nested objects differently. Replace the NULL sentinel with null in request bodies as well, so a param set to NULL is sent as null on either transport, and document how NULL tells an explicitly null param apart from an omitted one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PpqwDhZmyikGbjmCPqFW2A
1 parent 84b5485 commit da8f2f6

7 files changed

Lines changed: 333 additions & 13 deletions

File tree

README.rst

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@ Contents
4747

4848
* `Action Attempts`_
4949

50+
* `Setting a Param to Null`_
51+
5052
* `Pagination`_
5153

5254
* `Manually fetch pages with the next_page_cursor`_
@@ -280,6 +282,49 @@ For example:
280282
except SeamActionAttemptTimeoutError as e:
281283
print("Door took too long to unlock")
282284
285+
Setting a Param to Null
286+
~~~~~~~~~~~~~~~~~~~~~~~
287+
288+
The Seam API tells an omitted param apart from one explicitly set to null.
289+
In an update request, an omitted param leaves the current value unchanged,
290+
while a null param unsets it.
291+
292+
Python has a single absence value, so this SDK spells the two apart.
293+
A param set to ``None`` is omitted, and a param set to ``NULL`` is sent as null:
294+
295+
.. code-block:: python
296+
297+
from seam import NULL, Seam
298+
299+
seam = Seam()
300+
301+
# Leaves the name unchanged.
302+
seam.devices.update(device_id="your-device-id", name=None)
303+
304+
# Unsets the name.
305+
seam.devices.update(device_id="your-device-id", name=NULL)
306+
307+
Because unsetting a value cannot be undone, ``None`` means the safe option of
308+
omitting the param, and sending null is always explicit.
309+
This is why a param is never sent as null by default,
310+
even though ``None`` is the natural way to spell null in Python.
311+
312+
``NULL`` behaves the same way in a request body and in a URL search param.
313+
Its type is exported as ``Null`` for annotating your own code:
314+
315+
.. code-block:: python
316+
317+
from typing import Optional, Union
318+
319+
from seam import NULL, Null
320+
321+
name: Optional[Union[str, Null]] = NULL
322+
323+
Only use ``NULL`` where the Seam API documents null as a meaningful value,
324+
e.g., to unset a value in an update request.
325+
The generated method signatures do not yet say which params those are,
326+
so a type checker reports ``NULL`` as an invalid argument until they do.
327+
283328
Pagination
284329
~~~~~~~~~~
285330

@@ -562,8 +607,9 @@ A client may percent-encode a few characters differently than
562607
``URLSearchParams`` does, e.g. httpx escapes ``*`` and unescapes ``~``,
563608
which the Seam API reads as the same params either way.
564609

565-
A param set to ``None`` is omitted, while a param set to ``seam.NULL``
566-
is serialized to an empty value, which the Seam API reads as null.
610+
A param set to ``None`` is omitted, while a param set to ``NULL``
611+
is serialized to an empty value, which the Seam API reads as null,
612+
as described in `Setting a Param to Null`_.
567613
A param that cannot be represented raises a ``seam.UnserializableParamError``.
568614

569615
The Seam API parses these params with the corresponding `parser`_.

seam/client.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from collections.abc import Mapping
12
from typing import Any, Dict, Optional
23
from importlib.metadata import version
34
import abc
@@ -12,6 +13,8 @@
1213
SeamHttpInvalidInputError,
1314
SeamHttpUnauthorizedError,
1415
)
16+
from .null import replace_null
17+
from .url_search_params_serializer import serialize_url_search_params
1518

1619
SDK_HEADERS = {
1720
"seam-sdk-name": "seamapi/python",
@@ -102,6 +105,12 @@ def delete(self, url, json=None, **kwargs) -> Any:
102105
return self.request("DELETE", url, json=json, **kwargs)
103106

104107
def request(self, method, url, *args, **kwargs) -> Any:
108+
if isinstance(kwargs.get("params"), Mapping):
109+
url = with_search_params(url, kwargs.pop("params"))
110+
111+
if "json" in kwargs:
112+
kwargs["json"] = replace_null(kwargs["json"])
113+
105114
response = super().request(method, url, *args, **kwargs)
106115

107116
return self._handle_response(response)
@@ -142,6 +151,29 @@ def _handle_error_response(self, response: Response):
142151
raise SeamHttpApiError(error_details, status_code, request_id)
143152

144153

154+
def with_search_params(url: Any, params: Mapping[str, Any]) -> Any:
155+
"""Returns the url with the params serialized into its query string.
156+
157+
The Seam API parses URL search params as complex types, so the query
158+
string is built here and set on the url as-is. Handing the params to
159+
httpx instead would encode them with its own rules.
160+
161+
:param url: The url of the request
162+
:type url: Any
163+
164+
:param params: The search params of the request
165+
:type params: Mapping[str, Any]
166+
167+
:returns: The url carrying the serialized params"""
168+
169+
query = serialize_url_search_params(params)
170+
171+
if not query:
172+
return url
173+
174+
return httpx.URL(url, query=query.encode())
175+
176+
145177
def is_api_error_response(response: Response) -> bool:
146178
try:
147179
content_type = response.headers.get("content-type", "")

seam/null.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
Sending null is explicit and always spelled :data:`NULL`.
1111
"""
1212

13+
from collections.abc import Mapping, Sequence
1314
from typing import Any
1415

1516

@@ -60,3 +61,27 @@ def is_null(value: Any) -> bool:
6061
:returns: Whether the value is the ``NULL`` sentinel"""
6162

6263
return isinstance(value, Null)
64+
65+
66+
def replace_null(value: Any) -> Any:
67+
"""Returns a copy of a value with every :data:`NULL` sentinel replaced by ``None``.
68+
69+
The sentinel only distinguishes an explicit null from an omitted param
70+
within this SDK. Once a request body is being serialized, the param is
71+
known to be present, so the sentinel becomes the null that JSON has.
72+
73+
:param value: The value to copy
74+
:type value: Any
75+
76+
:returns: The value with each ``NULL`` sentinel replaced by ``None``"""
77+
78+
if is_null(value):
79+
return None
80+
81+
if isinstance(value, Mapping):
82+
return {key: replace_null(item) for key, item in value.items()}
83+
84+
if isinstance(value, Sequence) and not isinstance(value, (str, bytes)):
85+
return [replace_null(item) for item in value]
86+
87+
return value

test/conftest.py

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,8 @@ def recording_server(responses):
6262
content type inferred from the body, e.g. to serve malformed JSON.
6363
6464
Yields the endpoint along with the list of requests received so far.
65+
Each request records the ``method``, the raw request ``target``, the
66+
``path`` and ``query`` it splits into, the ``headers``, and the ``body``.
6567
"""
6668

6769
requests = []
@@ -70,21 +72,17 @@ def recording_server(responses):
7072
class Handler(BaseHTTPRequestHandler):
7173
protocol_version = "HTTP/1.1"
7274

73-
# pylint: disable-next=invalid-name
74-
def do_GET(self):
75-
self._handle_request()
76-
77-
# pylint: disable-next=invalid-name
78-
def do_POST(self):
79-
self._handle_request()
80-
8175
def _handle_request(self):
8276
content_length = int(self.headers.get("content-length", 0))
8377
raw_body = self.rfile.read(content_length)
78+
path, _, query = self.path.partition("?")
8479

8580
requests.append(
8681
{
87-
"path": self.path,
82+
"method": self.command,
83+
"target": self.path,
84+
"path": path,
85+
"query": query,
8886
"headers": {k.lower(): v for k, v in self.headers.items()},
8987
"body": json.loads(raw_body) if raw_body else None,
9088
}
@@ -112,6 +110,14 @@ def _handle_request(self):
112110
def log_message(self, *args):
113111
pass
114112

113+
# Every verb the SDK sends is recorded and answered the same way.
114+
# pylint: disable=invalid-name
115+
do_GET = _handle_request
116+
do_POST = _handle_request
117+
do_PUT = _handle_request
118+
do_PATCH = _handle_request
119+
do_DELETE = _handle_request
120+
115121
server = ThreadingHTTPServer(("localhost", 0), Handler)
116122
thread = threading.Thread(target=server.serve_forever, daemon=True)
117123
thread.start()

test/headers_test.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@ def test_seam_sends_default_headers(recording_server):
1717
assert len(requests) == 1
1818
[request] = requests
1919

20-
assert request["path"] == f"/devices/get?device_id={device_id}"
20+
assert request["path"] == "/devices/get"
21+
assert request["query"] == f"device_id={device_id}"
2122
assert request["body"] is None
2223

2324
assert request["headers"]["seam-sdk-name"] == "seamapi/python"

test/null_test.py

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from seam.null import NULL, Null, is_null
1+
from seam.null import NULL, Null, is_null, replace_null
22

33

44
def test_null_is_a_singleton():
@@ -20,3 +20,37 @@ def test_null_is_falsy():
2020

2121
def test_null_repr():
2222
assert repr(NULL) == "NULL"
23+
24+
25+
def test_replace_null_replaces_the_sentinel_with_none():
26+
assert replace_null(NULL) is None
27+
28+
29+
def test_replace_null_recurses_into_dicts_and_lists():
30+
assert replace_null(
31+
{
32+
"name": NULL,
33+
"properties": {"code": NULL, "kind": "lock"},
34+
"codes": [NULL, "1234", [NULL]],
35+
"pairs": (NULL, "1234"),
36+
}
37+
) == {
38+
"name": None,
39+
"properties": {"code": None, "kind": "lock"},
40+
"codes": [None, "1234", [None]],
41+
"pairs": [None, "1234"],
42+
}
43+
44+
45+
def test_replace_null_leaves_other_values_alone():
46+
values = [None, "", 0, False, "NULL", {"a": 1}, ["b"]]
47+
48+
assert replace_null(values) == values
49+
50+
51+
def test_replace_null_does_not_mutate_its_argument():
52+
body = {"name": NULL, "codes": [NULL]}
53+
54+
replace_null(body)
55+
56+
assert body == {"name": NULL, "codes": [NULL]}

0 commit comments

Comments
 (0)