From e91656919425b7db1176117c1198937e5e67dca3 Mon Sep 17 00:00:00 2001 From: Evan Sosenko Date: Thu, 13 Aug 2026 20:28:38 -0700 Subject: [PATCH] feat: Add _strict=true to url_search_params_serializer --- README.rst | 12 +++++++++--- seam/__init__.py | 5 ++--- seam/client.py | 2 +- seam/strict_url_search_params_serializer.py | 20 ++++++++++++++++++++ seam/url_search_params_serializer.py | 17 ++++++++++++++--- test/headers_test.py | 2 +- test/search_params_test.py | 13 +++++++------ test/url_search_params_serializer_test.py | 10 ++++++++++ 8 files changed, 64 insertions(+), 17 deletions(-) create mode 100644 seam/strict_url_search_params_serializer.py diff --git a/README.rst b/README.rst index d93f018a..3564b284 100644 --- a/README.rst +++ b/README.rst @@ -577,7 +577,13 @@ Serializing URL search params The Seam API parses URL search params as complex types. If you call it with your own HTTP client, -``serialize_url_search_params`` is exported for that purpose: +``serialize_url_search_params`` is exported for that purpose. + +.. note:: + + The ``_strict=true`` parameter is added to any non-empty query so the Seam API + uses strict, schema-aware parsing. A query with no serializable params remains + empty. .. code-block:: python @@ -604,10 +610,10 @@ as `URLSearchParams`_ does for the `reference implementation`_: update_url_search_params(search_params, {"device_ids": ["device1", "device2"]}) list(search_params) - # => [('device_ids', 'device1'), ('device_ids', 'device2')] + # => [('device_ids', 'device1'), ('device_ids', 'device2'), ('_strict', 'true')] str(search_params) - # => 'device_ids=device1&device_ids=device2' + # => 'device_ids=device1&device_ids=device2&_strict=true' Pass either the query string or the pairs to your HTTP client. A client may percent-encode a few characters differently than diff --git a/seam/__init__.py b/seam/__init__.py index 4c912626..ff7378fd 100644 --- a/seam/__init__.py +++ b/seam/__init__.py @@ -16,9 +16,8 @@ from .seam_webhook import SeamWebhook from svix.webhooks import WebhookVerificationError as SeamWebhookVerificationError from .null import NULL, Null -from .url_search_params_serializer import ( - UnserializableParamError, - UrlSearchParams, +from .url_search_params_serializer import UnserializableParamError, UrlSearchParams +from .strict_url_search_params_serializer import ( serialize_url_search_params, update_url_search_params, ) diff --git a/seam/client.py b/seam/client.py index 518fb8b1..18f6ace7 100644 --- a/seam/client.py +++ b/seam/client.py @@ -14,7 +14,7 @@ SeamHttpUnauthorizedError, ) from .null import replace_null -from .url_search_params_serializer import serialize_url_search_params +from .strict_url_search_params_serializer import serialize_url_search_params SDK_HEADERS = { "seam-sdk-name": "seamapi/python", diff --git a/seam/strict_url_search_params_serializer.py b/seam/strict_url_search_params_serializer.py new file mode 100644 index 00000000..97b346b1 --- /dev/null +++ b/seam/strict_url_search_params_serializer.py @@ -0,0 +1,20 @@ +"""Strict URL search parameter serializer used by the Seam SDK.""" + +from .url_search_params_serializer import ( + Params, + UrlSearchParams, + serialize_url_search_params as _serialize_url_search_params, + update_url_search_params as _update_url_search_params, +) + + +def serialize_url_search_params(params: Params) -> str: + """Serialize params with strict API validation enabled.""" + + return _serialize_url_search_params(params, strict=True) + + +def update_url_search_params(search_params: UrlSearchParams, params: Params) -> None: + """Update params with strict API validation enabled.""" + + _update_url_search_params(search_params, params, strict=True) diff --git a/seam/url_search_params_serializer.py b/seam/url_search_params_serializer.py index 973fc3df..076f06dd 100644 --- a/seam/url_search_params_serializer.py +++ b/seam/url_search_params_serializer.py @@ -216,11 +216,13 @@ def __iter__(self) -> Iterator[Tuple[str, str]]: return iter(self._pairs) -def serialize_url_search_params(params: Params) -> str: +def serialize_url_search_params(params: Params, *, strict: bool = False) -> str: """Serializes params to a URL search param query string. :param params: The params to serialize :type params: Mapping[str, Any] + :param strict: Whether to add ``_strict=true`` to non-empty query strings + :type strict: bool :returns: The query string, without a leading ``?`` @@ -228,12 +230,14 @@ def serialize_url_search_params(params: Params) -> str: """ search_params = UrlSearchParams() - update_url_search_params(search_params, params) + update_url_search_params(search_params, params, strict=strict) return search_params.to_string() -def update_url_search_params(search_params: UrlSearchParams, params: Params) -> None: +def update_url_search_params( + search_params: UrlSearchParams, params: Params, *, strict: bool = False +) -> None: """Updates existing URL search params with serialized params. Existing params are preserved unless overwritten by a serialized param. @@ -243,13 +247,20 @@ def update_url_search_params(search_params: UrlSearchParams, params: Params) -> :type search_params: UrlSearchParams :param params: The params to serialize :type params: Mapping[str, Any] + :param strict: Whether to add ``_strict=true`` when the result is non-empty + :type strict: bool :raises UnserializableParamError: If any param could not be serialized """ _nested_update_url_search_params(search_params, params, []) + search_params.sort() + if strict and len(search_params) > 0: + search_params.delete("_strict") + search_params.append("_strict", "true") + def _nested_update_url_search_params( search_params: UrlSearchParams, params: Params, path: List[str] diff --git a/test/headers_test.py b/test/headers_test.py index ffdf34e6..1f5c6b38 100644 --- a/test/headers_test.py +++ b/test/headers_test.py @@ -18,7 +18,7 @@ def test_seam_sends_default_headers(recording_server): [request] = requests assert request["path"] == "/devices/get" - assert request["query"] == f"device_id={device_id}" + assert request["query"] == f"device_id={device_id}&_strict=true" assert request["body"] is None assert request["headers"]["seam-sdk-name"] == "seamapi/python" diff --git a/test/search_params_test.py b/test/search_params_test.py index 79545daf..8465a964 100644 --- a/test/search_params_test.py +++ b/test/search_params_test.py @@ -31,6 +31,7 @@ def test_client_serializes_search_params(recording_server): "&device_ids=device1" "&device_ids=device2" "&limit=20" + "&_strict=true" ) @@ -48,7 +49,7 @@ def test_client_does_not_reencode_the_serialized_search_params(recording_server) [request] = requests - assert request["query"] == "search=a+*%7E+b" + assert request["query"] == "search=a+*%7E+b&_strict=true" def test_client_omits_search_params_set_to_none(recording_server): @@ -59,7 +60,7 @@ def test_client_omits_search_params_set_to_none(recording_server): [request] = requests - assert request["query"] == "limit=20" + assert request["query"] == "limit=20&_strict=true" def test_client_serializes_search_params_set_to_null(recording_server): @@ -70,7 +71,7 @@ def test_client_serializes_search_params_set_to_null(recording_server): [request] = requests - assert request["query"] == "limit=20&search=" + assert request["query"] == "limit=20&search=&_strict=true" def test_client_sends_no_query_string_without_search_params(recording_server): @@ -93,8 +94,8 @@ def test_client_serializes_search_params_of_every_verb(recording_server): seam.client.delete("/access_codes/delete", params={"sync": True}) assert [(request["method"], request["query"]) for request in requests] == [ - ("GET", "device_ids=device1"), - ("DELETE", "sync=true"), + ("GET", "device_ids=device1&_strict=true"), + ("DELETE", "sync=true&_strict=true"), ] @@ -173,4 +174,4 @@ def test_client_serializes_the_search_params_of_a_generated_route(recording_serv assert request["method"] == "GET" assert request["path"] == "/devices/get" - assert request["query"] == "name=Front+Door" + assert request["query"] == "name=Front+Door&_strict=true" diff --git a/test/url_search_params_serializer_test.py b/test/url_search_params_serializer_test.py index 48c94e04..ab70302d 100644 --- a/test/url_search_params_serializer_test.py +++ b/test/url_search_params_serializer_test.py @@ -16,6 +16,16 @@ def test_serializes_empty_object(): assert serialize_url_search_params({}) == "" +def test_strict_mode_adds_strict_to_non_empty_query_strings(): + assert serialize_url_search_params({}, strict=True) == "" + assert serialize_url_search_params({"foo": "d"}, strict=True) == ( + "foo=d&_strict=true" + ) + assert ( + serialize_url_search_params({"_strict": False}, strict=True) == "_strict=true" + ) + + def test_serializes_string(): assert serialize_url_search_params({"foo": "d"}) == "foo=d" assert serialize_url_search_params({"foo": "null"}) == "foo=null"