diff --git a/packages/smithy-http/src/smithy_http/serializers.py b/packages/smithy-http/src/smithy_http/serializers.py index a29b7667..163d2138 100644 --- a/packages/smithy-http/src/smithy_http/serializers.py +++ b/packages/smithy-http/src/smithy_http/serializers.py @@ -328,7 +328,7 @@ def __init__( :py:class:`HTTPHeaderTrait` will be checked instead. Required when collecting list entries. :param headers: An optional list of header tuples to append to. If not - set, one will be created. + set, one will be created. Values appended will not be escaped. """ self.headers: list[tuple[str, str]] = headers if headers is not None else [] self._key = key @@ -486,7 +486,7 @@ def write_big_decimal(self, schema: Schema, value: Decimal) -> None: def write_string(self, schema: Schema, value: str) -> None: key = self._key or schema.expect_trait(HTTPQueryTrait).key - self.query_params.append((key, urlquote(value, safe=""))) + self.query_params.append((key, value)) def write_timestamp(self, schema: Schema, value: datetime) -> None: key = self._key or schema.expect_trait(HTTPQueryTrait).key @@ -570,12 +570,28 @@ def __init__(self, query_params: list[tuple[str, str]]) -> None: :param query_params: The list of query param tuples to append to. """ self._query_params = query_params - self._delegate = CapturingSerializer() def entry(self, key: str, value_writer: Callable[[ShapeSerializer], None]): - value_writer(self._delegate) - assert self._delegate.result is not None # noqa: S101 - self._query_params.append((key, urlquote(self._delegate.result, safe=""))) + value_writer(HTTPQueryMapValueSerializer(key, self._query_params)) + + +class HTTPQueryMapValueSerializer(SpecificShapeSerializer): + def __init__(self, key: str, query_params: list[tuple[str, str]]) -> None: + """Initialize an HTTPQueryMapValueSerializer. + + :param key: The key of the query parameter. + :param query_params: The list of query param tuples to append to. + """ + self._key = key + self._query_params = query_params + + def write_string(self, schema: Schema, value: str) -> None: + # Note: values are escaped when query params are joined + self._query_params.append((self._key, value)) + + @contextmanager + def begin_list(self, schema: Schema, size: int) -> Iterator[ShapeSerializer]: + yield self class HostPrefixSerializer(SpecificShapeSerializer): diff --git a/packages/smithy-http/tests/unit/test_serializers.py b/packages/smithy-http/tests/unit/test_serializers.py index 199193bf..45912de1 100644 --- a/packages/smithy-http/tests/unit/test_serializers.py +++ b/packages/smithy-http/tests/unit/test_serializers.py @@ -1381,6 +1381,20 @@ def query_cases() -> list[HTTPMessageTestCase]: ) ), ), + HTTPMessageTestCase( + HTTPQuery(string_member="foo bar"), + HTTPMessage(destination=URI(host="", path="/", query="string=foo%20bar")), + ), + HTTPMessageTestCase( + HTTPQuery(string_list_member=["spam eggs", "eggs spam"]), + HTTPMessage( + destination=URI( + host="", + path="/", + query="stringList=spam%20eggs&stringList=eggs%20spam", + ) + ), + ), HTTPMessageTestCase( HTTPQuery( default_timestamp_member=datetime.datetime(2025, 1, 1, tzinfo=UTC)