diff --git a/README.rst b/README.rst index a031c5ce..70517550 100644 --- a/README.rst +++ b/README.rst @@ -73,6 +73,8 @@ Contents * `Configuring the httpx client`_ + * `Serializing URL search params`_ + * `Development and Testing`_ * `Quickstart`_ @@ -518,6 +520,58 @@ precedence over the defaults the SDK sets: }, ) +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: + +.. code-block:: python + + import httpx + from seam import serialize_url_search_params + + httpx.get( + "https://connect.getseam.com/devices/list", + params=serialize_url_search_params({"device_ids": ["device1", "device2"]}), + headers={"Authorization": "Bearer your-api-key"}, + ) + +The serialization defines the name and value of each search param, +where every value is a string. +``UrlSearchParams`` holds those pairs and renders the query string, +as `URLSearchParams`_ does for the `reference implementation`_: + +.. code-block:: python + + from seam import UrlSearchParams, update_url_search_params + + search_params = UrlSearchParams() + + update_url_search_params(search_params, {"device_ids": ["device1", "device2"]}) + + list(search_params) + # => [('device_ids', 'device1'), ('device_ids', 'device2')] + + str(search_params) + # => 'device_ids=device1&device_ids=device2' + +Pass either the query string or the pairs to your HTTP client. +A client may percent-encode a few characters differently than +``URLSearchParams`` does, e.g. httpx escapes ``*`` and unescapes ``~``, +which the Seam API reads as the same params either way. + +A param set to ``None`` is omitted, while a param set to ``seam.NULL`` +is serialized to an empty value, which the Seam API reads as null. +A param that cannot be represented raises a ``seam.UnserializableParamError``. + +The Seam API parses these params with the corresponding `parser`_. + +.. _URLSearchParams: https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams +.. _reference implementation: https://github.com/seamapi/url-search-params-serializer +.. _parser: https://github.com/seamapi/url-search-params-parser + Development and Testing ----------------------- diff --git a/seam/__init__.py b/seam/__init__.py index 30e17b6c..4c912626 100644 --- a/seam/__init__.py +++ b/seam/__init__.py @@ -15,3 +15,10 @@ ) 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, + serialize_url_search_params, + update_url_search_params, +) diff --git a/seam/null.py b/seam/null.py new file mode 100644 index 00000000..42c3b481 --- /dev/null +++ b/seam/null.py @@ -0,0 +1,62 @@ +"""The explicit null sentinel used by request params. + +Python has a single absence value, ``None``, but the Seam API distinguishes +an omitted param from a param explicitly set to null. For example, in an +update request, an omitted param leaves the current value unchanged, +while a null param unsets the current value. + +Since sending null is rarely intended and unsetting a value cannot be undone, +``None`` means the safe option of omitting the param. +Sending null is explicit and always spelled :data:`NULL`. +""" + +from typing import Any + + +class Null: + """Type of the :data:`NULL` sentinel.""" + + _instance = None + + def __new__(cls): + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance + + def __repr__(self): + return "NULL" + + def __bool__(self): + return False + + +NULL = Null() +"""Sentinel for a param explicitly set to null. + +Params set to this sentinel are serialized to null, +whereas params set to ``None`` are omitted: + +.. code-block:: python + + from seam import NULL, serialize_url_search_params + + serialize_url_search_params({"name": NULL, "limit": 20}) + # => 'limit=20&name=' + + serialize_url_search_params({"name": None, "limit": 20}) + # => 'limit=20' + +Use it wherever the Seam API documents null as a meaningful value, e.g., +to unset a value in an update request, or to filter by an unset value. +""" + + +def is_null(value: Any) -> bool: + """Returns whether a value is the :data:`NULL` sentinel. + + :param value: The value to check + :type value: Any + + :returns: Whether the value is the ``NULL`` sentinel""" + + return isinstance(value, Null) diff --git a/seam/url_search_params_serializer.py b/seam/url_search_params_serializer.py new file mode 100644 index 00000000..973fc3df --- /dev/null +++ b/seam/url_search_params_serializer.py @@ -0,0 +1,435 @@ +"""Serializes Python objects to URL search params. + +This is a Python port of the `@seamapi/url-search-params-serializer +`_ reference +implementation, which defines the standard for how the Seam SDKs and other +Seam API consumers serialize objects to URL search params in HTTP GET requests. + +Output is byte-for-byte identical to the reference implementation: +values are encoded with the ``application/x-www-form-urlencoded`` serializer, +params are sorted by name, and numbers are formatted using the +ECMAScript ``Number::toString`` algorithm. + +Type mapping between the reference implementation and this port: + +- JavaScript ``undefined`` is ``None``, or simply an absent key. +- JavaScript ``null`` is :data:`seam.NULL `. + Python has a single absence value, so ``None`` means the safe option of + omitting the param and sending null is always explicit. +- JavaScript ``string`` is ``str``. +- JavaScript ``boolean`` is ``bool``. +- JavaScript ``number`` is ``float`` or ``int``. +- JavaScript ``bigint`` is ``int``. + Python integers are arbitrary precision, so ``int`` covers both cases + and is always serialized in full without exponent notation. +- JavaScript ``Date`` and ``Temporal.Instant`` are + :class:`datetime.datetime`. + A naive ``datetime`` is interpreted as UTC. + Since ``Date`` has millisecond precision, microseconds are truncated. +- JavaScript ``Array`` is ``list`` or ``tuple``. + Unordered collections such as ``set`` are unsupported + because they would not serialize deterministically. +- A JavaScript plain object is any ``Mapping``, e.g., a ``dict``. +""" + +import datetime +import math +import string +from collections.abc import Mapping +from decimal import Decimal +from typing import Any, Iterator, List, Optional, Sequence, Tuple, Union +from urllib.parse import parse_qsl + +from .null import is_null + +Params = Mapping[str, Any] + + +class UnserializableParamError(Exception): + """Exception raised when a param could not be serialized. + + :ivar name: Name of the param that could not be serialized + :vartype name: str + """ + + def __init__(self, name: str, message: str): + """ + :param name: Name of the param that could not be serialized + :type name: str + :param message: Description of why the param could not be serialized + :type message: str + """ + + super().__init__(f"Could not serialize parameter: '{name}' {message}") + self.name = name + + +class UrlSearchParams: + """A mutable collection of URL search params. + + Implements the parts of the `URLSearchParams + `_ + interface needed to serialize params to a query string. + Unlike a ``dict``, a name may appear more than once, + which is how arrays are serialized. + """ + + def __init__( + self, + init: Optional[Union[str, Params, Sequence[Tuple[str, str]]]] = None, + ): + """ + :param init: A query string, a mapping of names to values, + or a sequence of name-value pairs + :type init: Optional[Union[str, Mapping[str, Any], Sequence[Tuple[str, str]]]] + """ + + self._pairs: List[Tuple[str, str]] = [] + + if init is None: + return + + if isinstance(init, str): + query = init[1:] if init.startswith("?") else init + self._pairs = list(parse_qsl(query, keep_blank_values=True)) + return + + items = init.items() if isinstance(init, Mapping) else init + self._pairs = [(str(name), str(value)) for name, value in items] + + def append(self, name: str, value: str) -> None: + """Appends a name-value pair, keeping any existing pairs with this name. + + :param name: Name of the param + :type name: str + :param value: Value of the param + :type value: str + """ + + self._pairs.append((name, value)) + + def set(self, name: str, value: str) -> None: + """Sets the value associated with a name. + + Replaces the first pair with this name and removes any others. + Appends a new pair if no pair with this name exists. + + :param name: Name of the param + :type name: str + :param value: Value of the param + :type value: str + """ + + if not self.has(name): + self.append(name, value) + return + + pairs: List[Tuple[str, str]] = [] + is_set = False + + for pair in self._pairs: + if pair[0] != name: + pairs.append(pair) + elif not is_set: + pairs.append((name, value)) + is_set = True + + self._pairs = pairs + + def get(self, name: str) -> Optional[str]: + """Returns the value of the first pair with this name. + + :param name: Name of the param + :type name: str + + :returns: The value, or ``None`` if no pair with this name exists + """ + + for existing_name, value in self._pairs: + if existing_name == name: + return value + + return None + + def get_all(self, name: str) -> List[str]: + """Returns the values of all pairs with this name, in insertion order. + + :param name: Name of the param + :type name: str + + :returns: The values""" + + return [value for existing_name, value in self._pairs if existing_name == name] + + def has(self, name: str) -> bool: + """Returns whether a pair with this name exists. + + :param name: Name of the param + :type name: str + + :returns: Whether a pair with this name exists""" + + return any(existing_name == name for existing_name, _ in self._pairs) + + def delete(self, name: str) -> None: + """Removes all pairs with this name. + + :param name: Name of the param + :type name: str + """ + + self._pairs = [pair for pair in self._pairs if pair[0] != name] + + def sort(self) -> None: + """Sorts all pairs by name. + + Sorting is stable, so the relative order of pairs + with the same name is preserved. + Names are compared by UTF-16 code units to match the + `URLSearchParams.sort() + `_ + specification. + """ + + self._pairs.sort(key=lambda pair: pair[0].encode("utf-16-be")) + + def to_string(self) -> str: + """Serializes all pairs to a query string. + + :returns: The query string, without a leading ``?``""" + + return "&".join( + f"{_encode_form_component(name)}={_encode_form_component(value)}" + for name, value in self._pairs + ) + + def __str__(self) -> str: + return self.to_string() + + def __repr__(self) -> str: + return f"{self.__class__.__name__}({self.to_string()!r})" + + def __len__(self) -> int: + return len(self._pairs) + + def __iter__(self) -> Iterator[Tuple[str, str]]: + return iter(self._pairs) + + +def serialize_url_search_params(params: Params) -> str: + """Serializes params to a URL search param query string. + + :param params: The params to serialize + :type params: Mapping[str, Any] + + :returns: The query string, without a leading ``?`` + + :raises UnserializableParamError: If any param could not be serialized + """ + + search_params = UrlSearchParams() + update_url_search_params(search_params, params) + + return search_params.to_string() + + +def update_url_search_params(search_params: UrlSearchParams, params: Params) -> None: + """Updates existing URL search params with serialized params. + + Existing params are preserved unless overwritten by a serialized param. + All params are sorted by name. + + :param search_params: The URL search params to update + :type search_params: UrlSearchParams + :param params: The params to serialize + :type params: Mapping[str, Any] + + :raises UnserializableParamError: If any param could not be serialized + """ + + _nested_update_url_search_params(search_params, params, []) + search_params.sort() + + +def _nested_update_url_search_params( + search_params: UrlSearchParams, params: Params, path: List[str] +) -> None: + for key, value in params.items(): + if not isinstance(key, str): + raise UnserializableParamError( + repr(key), + f"is a {type(key).__name__} which is unsupported as a parameter name", + ) + + if "." in key: + raise UnserializableParamError( + key, + 'contains one or more dots "." in its name which is unsupported', + ) + + current_path = [*path, key] + + if isinstance(value, Mapping): + _nested_update_url_search_params(search_params, value, current_path) + continue + + name = ".".join(current_path) + + if value is None: + continue + + if isinstance(value, str) and len(value) == 0: + continue + + if isinstance(value, (list, tuple)): + _update_url_search_params_from_array(search_params, name, value) + continue + + search_params.set(name, _serialize(name, value)) + + +def _update_url_search_params_from_array( + search_params: UrlSearchParams, name: str, values: Sequence[Any] +) -> None: + if len(values) == 0: + search_params.set(name, "") + return + + if len(values) == 1 and _is_empty_string(values[0]): + raise UnserializableParamError( + name, + "is a single element array containing the empty string which is unsupported", + ) + + if any(_is_empty_string(value) for value in values): + raise UnserializableParamError( + name, + "is an array containing the empty string which is unsupported", + ) + + if any(value is None or is_null(value) for value in values): + raise UnserializableParamError( + name, + "is an array containing null or undefined values which is unsupported", + ) + + for value in values: + search_params.append(name, _serialize(name, value)) + + +def _serialize(name: str, value: Any) -> str: + if is_null(value): + return "" + + if isinstance(value, str): + return value + + if isinstance(value, bool): + return "true" if value else "false" + + if isinstance(value, int): + return str(value) + + if isinstance(value, float): + return _format_number(name, value) + + if isinstance(value, datetime.datetime): + return _format_datetime(value) + + raise UnserializableParamError(name, f"is a {type(value).__name__}") + + +def _is_empty_string(value: Any) -> bool: + return isinstance(value, str) and len(value) == 0 + + +def _format_datetime(value: datetime.datetime) -> str: + if value.tzinfo is None: + value = value.replace(tzinfo=datetime.timezone.utc) + + utc_value = value.astimezone(datetime.timezone.utc) + milliseconds = utc_value.microsecond // 1000 + + return ( + f"{utc_value.year:04d}-{utc_value.month:02d}-{utc_value.day:02d}" + f"T{utc_value.hour:02d}:{utc_value.minute:02d}:{utc_value.second:02d}" + f".{milliseconds:03d}Z" + ) + + +def _format_number(name: str, value: float) -> str: + if math.isnan(value): + raise UnserializableParamError(name, "is NaN") + + if math.isinf(value): + raise UnserializableParamError( + name, "is Infinity" if value > 0 else "is -Infinity" + ) + + if value == 0: + return "0" + + sign = "-" if value < 0 else "" + _, digit_tuple, exponent = Decimal(repr(abs(value))).as_tuple() + + # The shortest digit string that round-trips, and the position of the + # decimal point relative to it, as required by the ECMAScript + # Number::toString algorithm. + digits = "".join(str(digit) for digit in digit_tuple) + point = int(exponent) + len(digits) + digits = digits.rstrip("0") + + return sign + _format_digits(digits, point) + + +def _format_digits(digits: str, point: int) -> str: + """Formats digits and a decimal point position per ECMAScript Number::toString. + + :param digits: Significant digits, without trailing zeros + :type digits: str + :param point: Position of the decimal point relative to the digits + :type point: int + + :returns: The formatted number""" + + count = len(digits) + + if count <= point <= 21: + return digits + "0" * (point - count) + + if 0 < point <= 21: + return f"{digits[:point]}.{digits[point:]}" + + if -6 < point <= 0: + return f"0.{'0' * -point}{digits}" + + exponent = point - 1 + exponent_sign = "+" if exponent >= 0 else "-" + mantissa = digits if count == 1 else f"{digits[0]}.{digits[1:]}" + + return f"{mantissa}e{exponent_sign}{abs(exponent)}" + + +_FORM_SAFE_CHARACTERS = frozenset(f"{string.ascii_letters}{string.digits}*-._") + + +def _encode_form_component(value: str) -> str: + """Percent-encodes a string using the ``application/x-www-form-urlencoded`` serializer. + + :param value: The string to encode + :type value: str + + :returns: The encoded string""" + + encoded = [] + + for byte in value.encode("utf-8"): + character = chr(byte) + if character in _FORM_SAFE_CHARACTERS: + encoded.append(character) + elif character == " ": + encoded.append("+") + else: + encoded.append(f"%{byte:02X}") + + return "".join(encoded) diff --git a/test/null_test.py b/test/null_test.py new file mode 100644 index 00000000..83db727c --- /dev/null +++ b/test/null_test.py @@ -0,0 +1,22 @@ +from seam.null import NULL, Null, is_null + + +def test_null_is_a_singleton(): + assert Null() is NULL + assert is_null(NULL) + assert is_null(Null()) + + +def test_null_is_not_none(): + assert NULL is not None + assert not is_null(None) + assert not is_null("") + assert not is_null(0) + + +def test_null_is_falsy(): + assert not NULL + + +def test_null_repr(): + assert repr(NULL) == "NULL" diff --git a/test/url_search_params_serializer_test.py b/test/url_search_params_serializer_test.py new file mode 100644 index 00000000..48c94e04 --- /dev/null +++ b/test/url_search_params_serializer_test.py @@ -0,0 +1,454 @@ +from collections import OrderedDict +from datetime import date, datetime, timedelta, timezone + +import pytest + +from seam.null import NULL +from seam.url_search_params_serializer import ( + UnserializableParamError, + UrlSearchParams, + serialize_url_search_params, + update_url_search_params, +) + + +def test_serializes_empty_object(): + assert serialize_url_search_params({}) == "" + + +def test_serializes_string(): + assert serialize_url_search_params({"foo": "d"}) == "foo=d" + assert serialize_url_search_params({"foo": "null"}) == "foo=null" + assert serialize_url_search_params({"foo": "None"}) == "foo=None" + assert serialize_url_search_params({"foo": "undefined"}) == "foo=undefined" + assert serialize_url_search_params({"foo": "0"}) == "foo=0" + + +def test_removes_the_empty_string(): + # Serializing the empty string would conflict with NULL. + assert serialize_url_search_params({"foo": ""}) == "" + assert serialize_url_search_params({"foo": "d", "bar": ""}) == "foo=d" + + +def test_serializes_int(): + assert serialize_url_search_params({"foo": 1}) == "foo=1" + assert serialize_url_search_params({"foo": 0}) == "foo=0" + assert serialize_url_search_params({"foo": -42}) == "foo=-42" + + +def test_serializes_arbitrary_precision_int(): + assert ( + serialize_url_search_params({"foo": 9007199254740993}) == "foo=9007199254740993" + ) + assert ( + serialize_url_search_params({"foo": 123456789012345678901234567890}) + == "foo=123456789012345678901234567890" + ) + + +def test_serializes_float(): + assert serialize_url_search_params({"foo": 23.8}) == "foo=23.8" + assert serialize_url_search_params({"foo": -23.8}) == "foo=-23.8" + assert serialize_url_search_params({"foo": 0.30000000000000004}) == ( + "foo=0.30000000000000004" + ) + + +def test_serializes_float_using_the_ecmascript_number_format(): + # A float is serialized exactly as JavaScript would serialize the number, + # which is not always the same as the Python repr. + assert serialize_url_search_params({"foo": 1.0}) == "foo=1" + assert serialize_url_search_params({"foo": -0.0}) == "foo=0" + assert serialize_url_search_params({"foo": 100.0}) == "foo=100" + assert serialize_url_search_params({"foo": 1e16}) == "foo=10000000000000000" + assert serialize_url_search_params({"foo": 1e20}) == "foo=100000000000000000000" + assert serialize_url_search_params({"foo": 1e21}) == "foo=1e%2B21" + assert serialize_url_search_params({"foo": 0.0001}) == "foo=0.0001" + assert serialize_url_search_params({"foo": 1e-6}) == "foo=0.000001" + assert serialize_url_search_params({"foo": 1e-7}) == "foo=1e-7" + assert serialize_url_search_params({"foo": 5e-324}) == "foo=5e-324" + assert serialize_url_search_params({"foo": 1.7976931348623157e308}) == ( + "foo=1.7976931348623157e%2B308" + ) + + +def test_serializes_bool(): + assert serialize_url_search_params({"foo": True}) == "foo=true" + assert serialize_url_search_params({"foo": False}) == "foo=false" + assert serialize_url_search_params({"foo": True, "bar": False}) == ( + "bar=false&foo=true" + ) + + +def test_removes_none_params(): + assert serialize_url_search_params({"bar": None}) == "" + assert serialize_url_search_params({"foo": 1, "bar": None}) == "foo=1" + + +def test_serializes_null_params(): + assert serialize_url_search_params({"bar": NULL}) == "bar=" + assert serialize_url_search_params({"foo": 1, "bar": NULL}) == "bar=&foo=1" + + +def test_removes_none_params_at_any_depth(): + assert serialize_url_search_params({"foo": {"bar": None, "baz": 1}}) == "foo.baz=1" + assert serialize_url_search_params({"foo": {"bar": None}}) == "" + + +def test_serializes_empty_array_params(): + assert serialize_url_search_params({"bar": []}) == "bar=" + assert serialize_url_search_params({"foo": 1, "bar": []}) == "bar=&foo=1" + assert serialize_url_search_params({"bar": ()}) == "bar=" + + +def test_serializes_array_params_with_one_value(): + assert serialize_url_search_params({"bar": ["a"]}) == "bar=a" + assert serialize_url_search_params({"foo": 1, "bar": ["a"]}) == "bar=a&foo=1" + + +def test_serializes_array_params_with_many_values(): + assert serialize_url_search_params({"foo": 1, "bar": ["a", "2"]}) == ( + "bar=a&bar=2&foo=1" + ) + assert serialize_url_search_params( + {"foo": 1, "bar": ["null", "2", "undefined"]} + ) == ("bar=null&bar=2&bar=undefined&foo=1") + + +def test_serializes_tuple_params(): + assert serialize_url_search_params({"bar": ("a", "2")}) == "bar=a&bar=2" + + +def test_serializes_array_params_with_mixed_values(): + assert serialize_url_search_params( + {"bar": [1, "a", True, datetime(1970, 1, 1, tzinfo=timezone.utc)]} + ) == ("bar=1&bar=a&bar=true&bar=1970-01-01T00%3A00%3A00.000Z") + + +def test_serializes_datetime(): + assert serialize_url_search_params( + {"foo": 1, "now": datetime(2025, 2, 24, 18, 44, 39, tzinfo=timezone.utc)} + ) == ("foo=1&now=2025-02-24T18%3A44%3A39.000Z") + + +def test_serializes_datetime_with_milliseconds(): + assert serialize_url_search_params( + { + "now": datetime( + 2025, 2, 24, 18, 44, 39, microsecond=123000, tzinfo=timezone.utc + ) + } + ) == ("now=2025-02-24T18%3A44%3A39.123Z") + + +def test_truncates_datetime_microseconds(): + assert serialize_url_search_params( + { + "now": datetime( + 2025, 2, 24, 18, 44, 39, microsecond=123999, tzinfo=timezone.utc + ) + } + ) == ("now=2025-02-24T18%3A44%3A39.123Z") + + +def test_serializes_datetime_as_utc(): + assert serialize_url_search_params( + {"now": datetime(2025, 2, 24, 13, 44, 39, tzinfo=timezone(timedelta(hours=-5)))} + ) == ("now=2025-02-24T18%3A44%3A39.000Z") + + +def test_serializes_naive_datetime_as_utc(): + assert serialize_url_search_params({"now": datetime(2025, 2, 24, 18, 44, 39)}) == ( + "now=2025-02-24T18%3A44%3A39.000Z" + ) + + +def test_serializes_datetime_before_the_epoch(): + assert serialize_url_search_params( + {"then": datetime(1969, 12, 31, 23, 59, 59, tzinfo=timezone.utc)} + ) == ("then=1969-12-31T23%3A59%3A59.000Z") + + +def test_serializes_dicts(): + assert serialize_url_search_params({"foo": 1, "bar": {"baz": "a"}}) == ( + "bar.baz=a&foo=1" + ) + + assert serialize_url_search_params({"foo": 1, "bar": {"baz": {"x": {"z": 1}}}}) == ( + "bar.baz.x.z=1&foo=1" + ) + + assert serialize_url_search_params( + {"foo": 1, "bar": {"baz": {"x": {"z": NULL}}}} + ) == ("bar.baz.x.z=&foo=1") + + assert serialize_url_search_params({"foo": 1, "bar": {"baz": [1, "a"]}}) == ( + "bar.baz=1&bar.baz=a&foo=1" + ) + + assert serialize_url_search_params({"foo": {}, "bar": 2}) == "bar=2" + + assert serialize_url_search_params({"foo": {"x": {}}, "bar": 2}) == "bar=2" + + assert serialize_url_search_params( + {"foo": {}, "bar": {"baz": {"x": {"z": NULL, "t": {}}, "q": {}}}} + ) == ("bar.baz.x.z=") + + +def test_serializes_dict_subclasses(): + assert serialize_url_search_params( + {"foo": OrderedDict([("bar", 1), ("baz", 2)])} + ) == ("foo.bar=1&foo.baz=2") + + +def test_sorts_params_by_name(): + assert serialize_url_search_params({"b": 1, "a": 2, "c": 3}) == "a=2&b=1&c=3" + assert serialize_url_search_params({"b": 1, "A": 2, "a": 3, "B": 4}) == ( + "A=2&B=4&a=3&b=1" + ) + assert serialize_url_search_params({"a10": 1, "a2": 2, "a1": 3}) == ( + "a1=3&a10=1&a2=2" + ) + assert serialize_url_search_params({"zz": 1, "a": {"z": 2, "b": 3}}) == ( + "a.b=3&a.z=2&zz=1" + ) + assert serialize_url_search_params({"ab": 1, "a": {"b": 2}}) == "a.b=2&ab=1" + + +def test_sorts_params_by_utf_16_code_unit(): + assert serialize_url_search_params({"￿": 1, "\U0001f600": 2}) == ( + "%F0%9F%98%80=2&%EF%BF%BF=1" + ) + + +def test_sorting_preserves_array_order(): + assert serialize_url_search_params({"b": ["3", "1", "2"], "a": 1}) == ( + "a=1&b=3&b=1&b=2" + ) + + +def test_encodes_params_as_form_urlencoded(): + assert serialize_url_search_params({"foo": "a b"}) == "foo=a+b" + assert serialize_url_search_params({"foo": "a+b"}) == "foo=a%2Bb" + assert serialize_url_search_params({"foo": "a~b"}) == "foo=a%7Eb" + assert serialize_url_search_params({"foo": "a*b"}) == "foo=a*b" + assert serialize_url_search_params({"foo": "abcXYZ019*-._"}) == "foo=abcXYZ019*-._" + assert serialize_url_search_params({"foo": "a&b=c?d#e/f"}) == ( + "foo=a%26b%3Dc%3Fd%23e%2Ff" + ) + assert serialize_url_search_params({"foo": "100%"}) == "foo=100%25" + assert serialize_url_search_params({"foo": "a\nb"}) == "foo=a%0Ab" + + +def test_encodes_unicode_params(): + assert serialize_url_search_params({"foo": "héllo wörld"}) == ( + "foo=h%C3%A9llo+w%C3%B6rld" + ) + assert serialize_url_search_params({"foo": "日本語"}) == ( + "foo=%E6%97%A5%E6%9C%AC%E8%AA%9E" + ) + assert serialize_url_search_params({"🔒": "a"}) == "%F0%9F%94%92=a" + assert serialize_url_search_params({"a b": 1}) == "a+b=1" + + +def test_cannot_serialize_keys_containing_a_dot(): + with pytest.raises(UnserializableParamError): + serialize_url_search_params({"foo.bar": 1}) + + with pytest.raises(UnserializableParamError): + serialize_url_search_params({"foo": {"bar.baz": 1}}) + + +def test_cannot_serialize_non_string_keys(): + with pytest.raises(UnserializableParamError): + serialize_url_search_params({1: "a"}) + + +def test_cannot_serialize_functions(): + with pytest.raises(UnserializableParamError): + serialize_url_search_params({"foo": lambda: None}) + + +def test_cannot_serialize_number_pointers(): + with pytest.raises(UnserializableParamError): + serialize_url_search_params({"foo": float("inf")}) + + with pytest.raises(UnserializableParamError): + serialize_url_search_params({"foo": float("-inf")}) + + with pytest.raises(UnserializableParamError): + serialize_url_search_params({"foo": float("nan")}) + + +def test_cannot_serialize_arbitrary_objects(): + class Device: + def __init__(self): + self.device_id = "a" + + with pytest.raises(UnserializableParamError): + serialize_url_search_params({"foo": Device()}) + + +def test_cannot_serialize_date(): + # A date is not an instant, so it has no unambiguous serialization. + with pytest.raises(UnserializableParamError): + serialize_url_search_params({"foo": date(2025, 2, 24)}) + + +def test_cannot_serialize_sets(): + # A set would not serialize deterministically. + with pytest.raises(UnserializableParamError): + serialize_url_search_params({"foo": {"a", "b"}}) + + +def test_cannot_serialize_array_params_with_unserializable_values(): + with pytest.raises(UnserializableParamError): + serialize_url_search_params({"foo": [""]}) + + with pytest.raises(UnserializableParamError): + serialize_url_search_params({"bar": ["a", None]}) + + with pytest.raises(UnserializableParamError): + serialize_url_search_params({"bar": ["a", NULL]}) + + with pytest.raises(UnserializableParamError): + serialize_url_search_params({"bar": ["a", ["s"]]}) + + with pytest.raises(UnserializableParamError): + serialize_url_search_params({"bar": ["a", []]}) + + with pytest.raises(UnserializableParamError): + serialize_url_search_params({"bar": ["a", [""]]}) + + with pytest.raises(UnserializableParamError): + serialize_url_search_params({"bar": ["a", {}]}) + + with pytest.raises(UnserializableParamError): + serialize_url_search_params({"bar": ["a", {"x": 2}]}) + + with pytest.raises(UnserializableParamError): + serialize_url_search_params({"bar": ["a", lambda: None]}) + + with pytest.raises(UnserializableParamError): + serialize_url_search_params({"foo": 1, "bar": ["", "a", ""]}) + + with pytest.raises(UnserializableParamError): + serialize_url_search_params({"foo": 1, "bar": ["", "a", "2"]}) + + with pytest.raises(UnserializableParamError): + serialize_url_search_params({"foo": 1, "bar": ["", "", ""]}) + + with pytest.raises(UnserializableParamError): + serialize_url_search_params({"foo": [1, float("nan")]}) + + +def test_unserializable_param_error_message(): + with pytest.raises(UnserializableParamError) as error: + serialize_url_search_params({"foo": {"bar.baz": 1}}) + + assert str(error.value) == ( + "Could not serialize parameter: 'bar.baz' contains one or more dots" + ' "." in its name which is unsupported' + ) + assert error.value.name == "bar.baz" + + +def test_unserializable_param_error_message_uses_the_full_path(): + with pytest.raises(UnserializableParamError) as error: + serialize_url_search_params({"foo": {"bar": float("nan")}}) + + assert str(error.value) == "Could not serialize parameter: 'foo.bar' is NaN" + + +def test_update_url_search_params(): + search_params = UrlSearchParams() + update_url_search_params(search_params, {"foo": "d", "bar": 2}) + + assert search_params.to_string() == "bar=2&foo=d" + + +def test_update_url_search_params_preserves_existing_params(): + search_params = UrlSearchParams([("foo", "bar")]) + update_url_search_params( + search_params, + {"name": "Dax", "age": 27, "is_admin": True, "tags": ["cars", "planes"]}, + ) + + assert search_params.to_string() == ( + "age=27&foo=bar&is_admin=true&name=Dax&tags=cars&tags=planes" + ) + + +def test_update_url_search_params_overwrites_existing_params(): + search_params = UrlSearchParams([("foo", "a"), ("bar", "x"), ("foo", "b")]) + update_url_search_params(search_params, {"foo": "new"}) + + assert search_params.to_string() == "bar=x&foo=new" + + +def test_update_url_search_params_appends_array_params(): + search_params = UrlSearchParams([("foo", "old")]) + update_url_search_params(search_params, {"foo": [1, 2]}) + + assert search_params.to_string() == "foo=old&foo=1&foo=2" + + +def test_update_url_search_params_keeps_existing_params_for_absent_values(): + for value in [None, "", {}]: + search_params = UrlSearchParams([("foo", "a")]) + update_url_search_params(search_params, {"foo": value}) + + assert search_params.to_string() == "foo=a" + + +def test_url_search_params_from_query_string(): + search_params = UrlSearchParams("?a=1&b=hello+world&c=%F0%9F%94%92&d") + + assert search_params.get("a") == "1" + assert search_params.get("b") == "hello world" + assert search_params.get("c") == "🔒" + assert search_params.get("d") == "" + assert search_params.to_string() == "a=1&b=hello+world&c=%F0%9F%94%92&d=" + + +def test_url_search_params_from_dict(): + assert UrlSearchParams({"a": "1", "b": "2"}).to_string() == "a=1&b=2" + + +def test_url_search_params_append_and_get(): + search_params = UrlSearchParams() + search_params.append("foo", "a") + search_params.append("foo", "b") + + assert search_params.get("foo") == "a" + assert search_params.get_all("foo") == ["a", "b"] + assert search_params.get("bar") is None + assert search_params.get_all("bar") == [] + assert len(search_params) == 2 + assert list(search_params) == [("foo", "a"), ("foo", "b")] + + +def test_url_search_params_set(): + search_params = UrlSearchParams([("foo", "a"), ("bar", "x"), ("foo", "b")]) + search_params.set("foo", "c") + + assert list(search_params) == [("foo", "c"), ("bar", "x")] + + search_params.set("baz", "y") + + assert search_params.get("baz") == "y" + + +def test_url_search_params_has_and_delete(): + search_params = UrlSearchParams([("foo", "a"), ("foo", "b")]) + + assert search_params.has("foo") + + search_params.delete("foo") + + assert not search_params.has("foo") + assert len(search_params) == 0 + + +def test_url_search_params_str(): + assert str(UrlSearchParams([("foo", "a b")])) == "foo=a+b"