Skip to content

Commit d214664

Browse files
committed
feat: implement the URL search params serialization standard
Port @seamapi/url-search-params-serializer to Python. It defines how the Seam SDKs and other API consumers serialize objects to URL search params, and the Seam API parses them with the corresponding parser. Output is byte-for-byte identical to the reference implementation: - Values are encoded with the application/x-www-form-urlencoded serializer, which differs from urllib in its treatment of "*" and "~". - Params are sorted by name, compared by UTF-16 code unit. - Floats are formatted using the ECMAScript Number::toString algorithm, which differs from repr for integral floats and around the exponent notation thresholds. The serialization defines the name and value of each param, where every value is a string, and leaves rendering the query string to URLSearchParams. UrlSearchParams is that layer here. Python has a single absence value, and the standard needs both: a param set to None is omitted, while a param set to NULL is serialized to an empty value, which the API reads as null. Nothing calls this yet. Serializing the params of a request is a separate change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PpqwDhZmyikGbjmCPqFW2A
1 parent a536e3a commit d214664

6 files changed

Lines changed: 1034 additions & 0 deletions

File tree

README.rst

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,8 @@ Contents
7373

7474
* `Configuring the httpx client`_
7575

76+
* `Serializing URL search params`_
77+
7678
* `Development and Testing`_
7779

7880
* `Quickstart`_
@@ -518,6 +520,58 @@ precedence over the defaults the SDK sets:
518520
},
519521
)
520522
523+
Serializing URL search params
524+
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
525+
526+
The Seam API parses URL search params as complex types.
527+
If you call it with your own HTTP client,
528+
``serialize_url_search_params`` is exported for that purpose:
529+
530+
.. code-block:: python
531+
532+
import httpx
533+
from seam import serialize_url_search_params
534+
535+
httpx.get(
536+
"https://connect.getseam.com/devices/list",
537+
params=serialize_url_search_params({"device_ids": ["device1", "device2"]}),
538+
headers={"Authorization": "Bearer your-api-key"},
539+
)
540+
541+
The serialization defines the name and value of each search param,
542+
where every value is a string.
543+
``UrlSearchParams`` holds those pairs and renders the query string,
544+
as `URLSearchParams`_ does for the `reference implementation`_:
545+
546+
.. code-block:: python
547+
548+
from seam import UrlSearchParams, update_url_search_params
549+
550+
search_params = UrlSearchParams()
551+
552+
update_url_search_params(search_params, {"device_ids": ["device1", "device2"]})
553+
554+
list(search_params)
555+
# => [('device_ids', 'device1'), ('device_ids', 'device2')]
556+
557+
str(search_params)
558+
# => 'device_ids=device1&device_ids=device2'
559+
560+
Pass either the query string or the pairs to your HTTP client.
561+
A client may percent-encode a few characters differently than
562+
``URLSearchParams`` does, e.g. httpx escapes ``*`` and unescapes ``~``,
563+
which the Seam API reads as the same params either way.
564+
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.
567+
A param that cannot be represented raises a ``seam.UnserializableParamError``.
568+
569+
The Seam API parses these params with the corresponding `parser`_.
570+
571+
.. _URLSearchParams: https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams
572+
.. _reference implementation: https://github.com/seamapi/url-search-params-serializer
573+
.. _parser: https://github.com/seamapi/url-search-params-parser
574+
521575
Development and Testing
522576
-----------------------
523577

seam/__init__.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,10 @@
1515
)
1616
from .seam_webhook import SeamWebhook
1717
from svix.webhooks import WebhookVerificationError as SeamWebhookVerificationError
18+
from .null import NULL, Null
19+
from .url_search_params_serializer import (
20+
UnserializableParamError,
21+
UrlSearchParams,
22+
serialize_url_search_params,
23+
update_url_search_params,
24+
)

seam/null.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
"""The explicit null sentinel used by request params.
2+
3+
Python has a single absence value, ``None``, but the Seam API distinguishes
4+
an omitted param from a param explicitly set to null. For example, in an
5+
update request, an omitted param leaves the current value unchanged,
6+
while a null param unsets the current value.
7+
8+
Since sending null is rarely intended and unsetting a value cannot be undone,
9+
``None`` means the safe option of omitting the param.
10+
Sending null is explicit and always spelled :data:`NULL`.
11+
"""
12+
13+
from typing import Any
14+
15+
16+
class Null:
17+
"""Type of the :data:`NULL` sentinel."""
18+
19+
_instance = None
20+
21+
def __new__(cls):
22+
if cls._instance is None:
23+
cls._instance = super().__new__(cls)
24+
return cls._instance
25+
26+
def __repr__(self):
27+
return "NULL"
28+
29+
def __bool__(self):
30+
return False
31+
32+
33+
NULL = Null()
34+
"""Sentinel for a param explicitly set to null.
35+
36+
Params set to this sentinel are serialized to null,
37+
whereas params set to ``None`` are omitted:
38+
39+
.. code-block:: python
40+
41+
from seam import NULL, serialize_url_search_params
42+
43+
serialize_url_search_params({"name": NULL, "limit": 20})
44+
# => 'limit=20&name='
45+
46+
serialize_url_search_params({"name": None, "limit": 20})
47+
# => 'limit=20'
48+
49+
Use it wherever the Seam API documents null as a meaningful value, e.g.,
50+
to unset a value in an update request, or to filter by an unset value.
51+
"""
52+
53+
54+
def is_null(value: Any) -> bool:
55+
"""Returns whether a value is the :data:`NULL` sentinel.
56+
57+
:param value: The value to check
58+
:type value: Any
59+
60+
:returns: Whether the value is the ``NULL`` sentinel"""
61+
62+
return isinstance(value, Null)

0 commit comments

Comments
 (0)