diff --git a/src/httpx2/httpx2/_models.py b/src/httpx2/httpx2/_models.py index e6aeabd6..b50adbd9 100644 --- a/src/httpx2/httpx2/_models.py +++ b/src/httpx2/httpx2/_models.py @@ -45,7 +45,7 @@ ResponseExtensions, SyncByteStream, ) -from ._urls import URL +from ._urls import URL, QueryParams from ._utils import to_bytes_or_str, to_str __all__ = ["Cookies", "Headers", "Request", "Response"] @@ -385,7 +385,18 @@ def __init__( extensions: RequestExtensions | None = None, ) -> None: self.method = method.upper() - self.url = URL(url) if params is None else URL(url, params=params) + if params is None: + self.url = URL(url) + else: + base_url = URL(url) + params_obj = QueryParams(params) + if params_obj: + new_params_bytes = str(params_obj).encode("ascii") + existing_query = base_url.query # b"" if no query + merged_query = (existing_query + b"&" + new_params_bytes) if existing_query else new_params_bytes + self.url = base_url.copy_with(query=merged_query) + else: + self.url = base_url self.headers = Headers(headers) self.extensions = {} if extensions is None else dict(extensions) diff --git a/tests/httpx2/client/test_queryparams.py b/tests/httpx2/client/test_queryparams.py index c46780d3..e16cb453 100644 --- a/tests/httpx2/client/test_queryparams.py +++ b/tests/httpx2/client/test_queryparams.py @@ -31,3 +31,30 @@ def test_client_queryparams_echo(): assert response.status_code == 200 assert response.url == "http://example.org/echo_queryparams?first=str&second=dict" + + +def test_base_url_with_request_params(): + # Query params in the request URL must not be dropped when request-level + # params are also passed. + client = httpx2.Client(base_url="https://api.example.com/v1/") + request = client.build_request("GET", "users?active=true", params={"page": "2"}) + + assert str(request.url) == "https://api.example.com/v1/users?active=true&page=2" + + +def test_base_url_with_client_params_and_url_query(): + # Client-level params must be appended to query params already present in + # the request URL, not replace them. + client = httpx2.Client(base_url="https://api.example.com/v1/", params={"api_key": "abc"}) + request = client.build_request("GET", "users?active=true") + + assert str(request.url) == "https://api.example.com/v1/users?active=true&api_key=abc" + + +def test_base_url_with_client_params_request_params_and_url_query(): + # All three sources of query params (URL, client-level, request-level) + # must be combined without any being dropped. + client = httpx2.Client(base_url="https://api.example.com/v1/", params={"api_key": "abc"}) + request = client.build_request("GET", "users?active=true", params={"page": "2"}) + + assert str(request.url) == "https://api.example.com/v1/users?active=true&api_key=abc&page=2" diff --git a/tests/httpx2/models/test_requests.py b/tests/httpx2/models/test_requests.py index 08be406a..d509672c 100644 --- a/tests/httpx2/models/test_requests.py +++ b/tests/httpx2/models/test_requests.py @@ -230,8 +230,16 @@ def test_request_params(): request = httpx2.Request("GET", "http://example.com", params={}) assert str(request.url) == "http://example.com" + # params are appended to, not replacing, any existing query in the URL request = httpx2.Request("GET", "http://example.com?c=3", params={"a": "1", "b": "2"}) - assert str(request.url) == "http://example.com?a=1&b=2" + assert str(request.url) == "http://example.com?c=3&a=1&b=2" request = httpx2.Request("GET", "http://example.com?a=1", params={}) - assert str(request.url) == "http://example.com" + assert str(request.url) == "http://example.com?a=1" + + +def test_request_params_no_double_encoding(): + # The existing query string must not be reparsed through QueryParams; + # doing so risks double-encoding or reordering of existing parameters. + request = httpx2.Request("GET", "http://example.com?q=hello%20world", params={"page": "2"}) + assert str(request.url) == "http://example.com?q=hello%20world&page=2"