Repository: encode/httpx

Issue:
Query params should support list-of-strings values.
Hi,

I recently tried to use `httpx` instead of `requests` in my simple app that calls few API endpoints and  suddenly it did not work.

The problem here is that `httpx` and `requests` process query parameters differently.

Here's a simple script to see how both libraries process `params`:
```python
from httpx.models import QueryParams
from requests.models import RequestEncodingMixin

dict_params = {
    "documentIds": ["a55b2f894b90dd1213201471_25", "b54b2f894b90dd1213201471_9"]
}

httpx_params = str(QueryParams(dict_params))
print("httpx", httpx_params)

requests_params = RequestEncodingMixin._encode_params(dict_params)
print("requests", requests_params)
```

Results:
```
httpx documentIds=%5B%27a55b2f894b90dd1213201471_25%27%2C+%27b54b2f894b90dd1213201471_9%27%5D
requests documentIds=a55b2f894b90dd1213201471_25&documentIds=b54b2f894b90dd1213201471_9
```

If we try to parse the query string `httpx` produced, here's what we get:
```python
from urllib.parse import parse_qs

httpx_params_parsed = parse_qs('documentIds=%5B%27a55b2f894b90dd1213201471_25%27%2C+%27b54b2f894b90dd1213201471_9%27%5D')
print(httpx_params_parsed )
```
Result:
```
{'documentIds': ["['a55b2f894b90dd1213201471_25', 'b54b2f894b90dd1213201471_9']"]}
```
Which is not what you would expect, I guess. At least, it works differently than in `requests`.

If it is an intended behavior, can you please recommend me the proper way of passing a list into the query params?

Thanks!


Discussion:
Here's a bit more "live" example:
```python
import httpx
import requests

URL = "https://httpbin.org/get"
PARAMS = {"documentIds": ["a55b2f894b90dd1213201471_25", "b54b2f894b90dd1213201471_9"]}


r = requests.get(URL, params=PARAMS)
print("requests", r.json()["args"])

r = httpx.get(URL, params=PARAMS)
print("httpx", r.json()["args"])
```

Results:
```
requests {'documentIds': ['a55b2f894b90dd1213201471_25', 'b54b2f894b90dd1213201471_9']}
httpx {'documentIds': "['a55b2f894b90dd1213201471_25', 'b54b2f894b90dd1213201471_9']"}
```
Hi @Senpos! Thank you for reporting this.

I can confirm this is a bug: multivalued query params are encoded in the form of a Python list. I discovered it in #298 but didn't think about open an issue:

https://github.com/encode/httpx/blob/39348dd969a8900b682921566456342881ce396f/tests/compat/test_requests.py#L1006-L1019
We should look at adding some requests compatibility tests for this if they exist. :)
Thanks for opening the issue!
Retitled to make this more clearly specific.

Resolving patch:
diff --git a/httpx/models.py b/httpx/models.py
--- a/httpx/models.py
+++ b/httpx/models.py
@@ -32,6 +32,7 @@
 from .multipart import multipart_encode
 from .status_codes import StatusCode
 from .utils import (
+    flatten_queryparams,
     guess_json_utf,
     is_known_encoding,
     normalize_header_key,
@@ -51,7 +52,7 @@
 
 QueryParamTypes = typing.Union[
     "QueryParams",
-    typing.Mapping[str, PrimitiveData],
+    typing.Mapping[str, typing.Union[PrimitiveData, typing.Sequence[PrimitiveData]]],
     typing.List[typing.Tuple[str, PrimitiveData]],
     str,
 ]
@@ -311,14 +312,15 @@ def __init__(self, *args: QueryParamTypes, **kwargs: typing.Any) -> None:
 
         value = args[0] if args else kwargs
 
+        items: typing.Sequence[typing.Tuple[str, PrimitiveData]]
         if isinstance(value, str):
             items = parse_qsl(value)
         elif isinstance(value, QueryParams):
             items = value.multi_items()
         elif isinstance(value, list):
-            items = value  # type: ignore
+            items = value
         else:
-            items = value.items()  # type: ignore
+            items = flatten_queryparams(value)
 
         self._list = [(str(k), str_query_param(v)) for k, v in items]
         self._dict = {str(k): str_query_param(v) for k, v in items}
diff --git a/httpx/utils.py b/httpx/utils.py
--- a/httpx/utils.py
+++ b/httpx/utils.py
@@ -1,4 +1,5 @@
 import codecs
+import collections
 import logging
 import netrc
 import os
@@ -11,6 +12,9 @@
 from types import TracebackType
 from urllib.request import getproxies
 
+if typing.TYPE_CHECKING:  # pragma: no cover
+    from .models import PrimitiveData
+
 
 def normalize_header_key(value: typing.AnyStr, encoding: str = None) -> bytes:
     """
@@ -30,7 +34,7 @@ def normalize_header_value(value: typing.AnyStr, encoding: str = None) -> bytes:
     return value.encode(encoding or "ascii")
 
 
-def str_query_param(value: typing.Optional[typing.Union[str, int, float, bool]]) -> str:
+def str_query_param(value: "PrimitiveData") -> str:
     """
     Coerce a primitive data type into a string value for query params.
 
@@ -256,6 +260,31 @@ def unquote(value: str) -> str:
     return value[1:-1] if value[0] == value[-1] == '"' else value
 
 
+def flatten_queryparams(
+    queryparams: typing.Mapping[
+        str, typing.Union["PrimitiveData", typing.Sequence["PrimitiveData"]]
+    ]
+) -> typing.List[typing.Tuple[str, "PrimitiveData"]]:
+    """
+    Convert a mapping of query params into a flat list of two-tuples
+    representing each item.
+
+    Example:
+    >>> flatten_queryparams_values({"q": "httpx", "tag": ["python", "dev"]})
+    [("q", "httpx), ("tag", "python"), ("tag", "dev")]
+    """
+    items = []
+
+    for k, v in queryparams.items():
+        if isinstance(v, collections.abc.Sequence) and not isinstance(v, (str, bytes)):
+            for u in v:
+                items.append((k, u))
+        else:
+            items.append((k, typing.cast("PrimitiveData", v)))
+
+    return items
+
+
 class ElapsedTimer:
     def __init__(self) -> None:
         self.start: float = perf_counter()


Test patch:
diff --git a/tests/models/test_queryparams.py b/tests/models/test_queryparams.py
--- a/tests/models/test_queryparams.py
+++ b/tests/models/test_queryparams.py
@@ -1,8 +1,18 @@
+import pytest
+
 from httpx import QueryParams
 
 
-def test_queryparams():
-    q = QueryParams("a=123&a=456&b=789")
+@pytest.mark.parametrize(
+    "source",
+    [
+        "a=123&a=456&b=789",
+        {"a": ["123", "456"], "b": 789},
+        {"a": ("123", "456"), "b": 789},
+    ],
+)
+def test_queryparams(source):
+    q = QueryParams(source)
     assert "a" in q
     assert "A" not in q
     assert "c" not in q