Skip to content

Commit 5bd53ac

Browse files
committed
refactor: build search params through the URLSearchParams layer
The serialization defines the name and value of each search param, where every value is a string, and leaves rendering the query string to URLSearchParams. UrlSearchParams is that layer here, so hand the query it renders to httpx as params, the way the reference implementation hands it to axios as a paramsSerializer. httpx re-encodes the query it is given, escaping "*" and unescaping "~", so the earlier commit set the query on the URL to keep those bytes. That was unnecessary: re-encoding changes no param. Across the 3141 query strings of the randomized corpus, 1714 differ from ours in bytes and none differ in decoded name-value pairs. The README said to avoid a client's params for that reason, which was wrong. Describe the pairs and the layer that renders them instead. Narrow the null module to NULL and the Null type it instantiates. Whether a value is the sentinel, and replacing it for JSON serialization, are internal concerns of this SDK: a caller building their own request body writes None, which already serializes to null. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PpqwDhZmyikGbjmCPqFW2A
1 parent 946a7d0 commit 5bd53ac

5 files changed

Lines changed: 62 additions & 44 deletions

File tree

README.rst

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -565,20 +565,39 @@ If you call the Seam API with your own HTTP client,
565565
import httpx
566566
from seam import serialize_url_search_params
567567
568-
query = serialize_url_search_params({"device_ids": ["device1", "device2"]})
569-
570568
httpx.get(
571-
f"https://connect.getseam.com/devices/list?{query}",
569+
"https://connect.getseam.com/devices/list",
570+
params=serialize_url_search_params({"device_ids": ["device1", "device2"]}),
572571
headers={"Authorization": "Bearer your-api-key"},
573572
)
574573
575-
It returns a query string, so it works with any HTTP client.
576-
Put it on the URL as above rather than handing it to the client as params:
577-
clients re-encode a query string they are given, e.g. httpx escapes ``*``
578-
and unescapes ``~``, which this serialization does not.
574+
The serialization defines the name and value of each search param,
575+
where every value is a string.
576+
``UrlSearchParams`` holds those pairs and renders the query string,
577+
as `URLSearchParams`_ does for the `reference implementation`_:
578+
579+
.. code-block:: python
580+
581+
from seam import UrlSearchParams, update_url_search_params
582+
583+
search_params = UrlSearchParams()
584+
585+
update_url_search_params(search_params, {"device_ids": ["device1", "device2"]})
586+
587+
list(search_params)
588+
# => [('device_ids', 'device1'), ('device_ids', 'device2')]
589+
590+
str(search_params)
591+
# => 'device_ids=device1&device_ids=device2'
592+
593+
Pass either the query string or the pairs to your HTTP client.
594+
A client may percent-encode a few characters differently than
595+
``URLSearchParams`` does, e.g. httpx escapes ``*`` and unescapes ``~``,
596+
which the Seam API reads as the same params either way.
597+
598+
The Seam API parses these params with the corresponding `parser`_.
579599

580-
The `reference implementation`_ defines this serialization,
581-
and the Seam API parses it with the corresponding `parser`_.
600+
.. _URLSearchParams: https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams
582601

583602
.. _reference implementation: https://github.com/seamapi/url-search-params-serializer
584603
.. _parser: https://github.com/seamapi/url-search-params-parser

seam/client.py

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
SeamHttpInvalidInputError,
1414
SeamHttpUnauthorizedError,
1515
)
16-
from .null import replace_null
16+
from .null import _replace_null
1717
from .utils.url_search_params_serializer import serialize_url_search_params
1818

1919
SDK_HEADERS = {
@@ -97,17 +97,14 @@ def request(self, method, url, *args, **kwargs) -> Any:
9797
# Route methods omit params set to None, so any remaining NULL sentinel
9898
# is an explicit null and becomes None for JSON serialization.
9999
if "json" in kwargs:
100-
kwargs["json"] = replace_null(kwargs["json"])
100+
kwargs["json"] = _replace_null(kwargs["json"])
101101

102102
# Search params are serialized to the Seam API standard, which httpx
103103
# does not implement. The NULL sentinel is serialized to an empty value.
104-
# The query is set on the URL rather than passed to httpx as params,
105-
# because httpx re-encodes a query string it is given, e.g. it escapes
106-
# "*" and unescapes "~", which the standard does not.
104+
# httpx percent-encodes a few characters differently than the standard
105+
# when it re-encodes the query, which the Seam API reads the same way.
107106
if isinstance(kwargs.get("params"), Mapping):
108-
query = serialize_url_search_params(kwargs.pop("params"))
109-
if query:
110-
url = httpx.URL(url, query=query.encode())
107+
kwargs["params"] = serialize_url_search_params(kwargs["params"])
111108

112109
response = super().request(method, url, *args, **kwargs)
113110

seam/null.py

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
from collections.abc import Mapping
1414
from typing import Any
1515

16+
__all__ = ["NULL", "Null"]
17+
1618

1719
class Null:
1820
"""Type of the :data:`NULL` sentinel."""
@@ -57,7 +59,7 @@ def __bool__(self):
5759
"""
5860

5961

60-
def is_null(value: Any) -> bool:
62+
def _is_null(value: Any) -> bool:
6163
"""Returns whether a value is the :data:`NULL` sentinel.
6264
6365
:param value: The value to check
@@ -68,7 +70,7 @@ def is_null(value: Any) -> bool:
6870
return isinstance(value, Null)
6971

7072

71-
def replace_null(value: Any) -> Any:
73+
def _replace_null(value: Any) -> Any:
7274
"""Recursively replaces the :data:`NULL` sentinel with ``None``.
7375
7476
Returns a copy, so the given value is never modified.
@@ -80,16 +82,16 @@ def replace_null(value: Any) -> Any:
8082
8183
:returns: A copy of the value with every ``NULL`` sentinel replaced"""
8284

83-
if is_null(value):
85+
if _is_null(value):
8486
return None
8587

8688
if isinstance(value, Mapping):
87-
return {key: replace_null(item) for key, item in value.items()}
89+
return {key: _replace_null(item) for key, item in value.items()}
8890

8991
if isinstance(value, list):
90-
return [replace_null(item) for item in value]
92+
return [_replace_null(item) for item in value]
9193

9294
if isinstance(value, tuple):
93-
return tuple(replace_null(item) for item in value)
95+
return tuple(_replace_null(item) for item in value)
9496

9597
return value

seam/utils/url_search_params_serializer.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@
4040
from typing import Any, Iterator, List, Optional, Sequence, Tuple, Union
4141
from urllib.parse import parse_qsl
4242

43-
from ..null import is_null
43+
from ..null import _is_null
4444

4545
Params = Mapping[str, Any]
4646

@@ -307,7 +307,7 @@ def _update_url_search_params_from_array(
307307
"is an array containing the empty string which is unsupported",
308308
)
309309

310-
if any(value is None or is_null(value) for value in values):
310+
if any(value is None or _is_null(value) for value in values):
311311
raise UnserializableParamError(
312312
name,
313313
"is an array containing null or undefined values which is unsupported",
@@ -318,7 +318,7 @@ def _update_url_search_params_from_array(
318318

319319

320320
def _serialize(name: str, value: Any) -> str:
321-
if is_null(value):
321+
if _is_null(value):
322322
return ""
323323

324324
if isinstance(value, str):

test/null_test.py

Lines changed: 18 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,20 @@
11
from collections import OrderedDict
22

33
from seam.client import SeamHttpClient
4-
from seam.null import NULL, Null, is_null, replace_null
4+
from seam.null import NULL, Null, _is_null, _replace_null
55

66

77
def test_null_is_a_singleton():
88
assert Null() is NULL
9-
assert is_null(NULL)
10-
assert is_null(Null())
9+
assert _is_null(NULL)
10+
assert _is_null(Null())
1111

1212

1313
def test_null_is_not_none():
1414
assert NULL is not None
15-
assert not is_null(None)
16-
assert not is_null("")
17-
assert not is_null(0)
15+
assert not _is_null(None)
16+
assert not _is_null("")
17+
assert not _is_null(0)
1818

1919

2020
def test_null_is_falsy():
@@ -26,40 +26,40 @@ def test_null_repr():
2626

2727

2828
def test_replace_null():
29-
assert replace_null(NULL) is None
30-
assert replace_null(None) is None
31-
assert replace_null("a") == "a"
32-
assert replace_null(0) == 0
33-
assert replace_null(False) is False
29+
assert _replace_null(NULL) is None
30+
assert _replace_null(None) is None
31+
assert _replace_null("a") == "a"
32+
assert _replace_null(0) == 0
33+
assert _replace_null(False) is False
3434

3535

3636
def test_replace_null_in_dict():
37-
assert replace_null({"a": NULL, "b": 1, "c": None}) == {
37+
assert _replace_null({"a": NULL, "b": 1, "c": None}) == {
3838
"a": None,
3939
"b": 1,
4040
"c": None,
4141
}
4242

4343

4444
def test_replace_null_in_nested_dict():
45-
assert replace_null({"a": {"b": {"c": NULL}}}) == {"a": {"b": {"c": None}}}
45+
assert _replace_null({"a": {"b": {"c": NULL}}}) == {"a": {"b": {"c": None}}}
4646

4747

4848
def test_replace_null_in_lists_and_tuples():
49-
assert replace_null(["a", NULL]) == ["a", None]
50-
assert replace_null(("a", NULL)) == ("a", None)
51-
assert replace_null({"a": [{"b": NULL}]}) == {"a": [{"b": None}]}
49+
assert _replace_null(["a", NULL]) == ["a", None]
50+
assert _replace_null(("a", NULL)) == ("a", None)
51+
assert _replace_null({"a": [{"b": NULL}]}) == {"a": [{"b": None}]}
5252

5353

5454
def test_replace_null_does_not_modify_the_given_value():
5555
params = {"a": NULL, "b": [NULL]}
56-
replace_null(params)
56+
_replace_null(params)
5757

5858
assert params == {"a": NULL, "b": [NULL]}
5959

6060

6161
def test_replace_null_normalizes_mappings_to_dicts():
62-
result = replace_null(OrderedDict([("a", NULL)]))
62+
result = _replace_null(OrderedDict([("a", NULL)]))
6363

6464
assert result == {"a": None}
6565

0 commit comments

Comments
 (0)