Skip to content

Commit d752c5d

Browse files
committed
fix: keep the URL's query string when params are passed
httpx2 replaces a URL's query whenever params are sent, including client-level params (pydantic/httpx2#905). Fold the URL query into params in build_request, the per-verb methods and stream, for both clients. Precedence: client params < URL query < per-request params.
1 parent 1943777 commit d752c5d

2 files changed

Lines changed: 131 additions & 8 deletions

File tree

‎src/httpware/client.py‎

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,16 @@ def _assemble_request_kwargs( # noqa: PLR0913 — 9 per-request kwargs from htt
145145
return kwargs
146146

147147

148+
def _merge_url_query(
149+
url: httpx2.URL | str, params: typing.Any | None, client_params: httpx2.QueryParams
150+
) -> tuple[httpx2.URL | str, typing.Any | None]:
151+
"""Fold the URL's own query into `params`; httpx2 would otherwise replace it."""
152+
parsed = httpx2.URL(url)
153+
if not parsed.query or (params is None and not client_params):
154+
return url, params
155+
return parsed.copy_with(query=None), parsed.params.merge(params)
156+
157+
148158
class AsyncClient:
149159
"""Async HTTP client: thin wrapper around httpx2 with typed decoding and middleware."""
150160

@@ -261,8 +271,9 @@ async def send_with_response(
261271
return response, bound.decode(response)
262272

263273
def build_request(self, method: str, url: str, **kwargs: typing.Any) -> httpx2.Request:
264-
"""Delegate request construction to the wrapped httpx2.AsyncClient."""
265-
return self._httpx2_client.build_request(method, url, **kwargs)
274+
"""Delegate request construction to the wrapped httpx2.AsyncClient, keeping the URL's own query."""
275+
merged_url, params = _merge_url_query(url, kwargs.pop("params", None), self._httpx2_client.params)
276+
return self._httpx2_client.build_request(method, merged_url, params=params, **kwargs)
266277

267278
def _prepare_request( # noqa: PLR0913 — mirrors httpx2 per-method signatures; kwargs-forwarding complexity is structural
268279
self,
@@ -279,6 +290,7 @@ def _prepare_request( # noqa: PLR0913 — mirrors httpx2 per-method signatures;
279290
data: typing.Any | None = None,
280291
files: typing.Any | None = None,
281292
) -> httpx2.Request:
293+
merged_url, params = _merge_url_query(url, params, self._httpx2_client.params)
282294
kwargs = _assemble_request_kwargs(
283295
params=params,
284296
headers=headers,
@@ -290,7 +302,7 @@ def _prepare_request( # noqa: PLR0913 — mirrors httpx2 per-method signatures;
290302
data=data,
291303
files=files,
292304
)
293-
request = self._httpx2_client.build_request(method, url, **kwargs)
305+
request = self._httpx2_client.build_request(method, merged_url, **kwargs)
294306
if _is_streaming_body_async(content) or _is_streaming_body_async(data) or _is_streaming_body_async(files):
295307
request.extensions[STREAMING_BODY_MARKER] = True
296308
return request
@@ -1046,6 +1058,7 @@ async def stream( # noqa: PLR0913 — mirrors httpx2 per-method signatures; kwa
10461058
Maps httpx2 exceptions raised during the request OR body consumption to
10471059
httpware exceptions via _httpx2_exception_mapper.
10481060
"""
1061+
merged_url, params = _merge_url_query(url, params, self._httpx2_client.params)
10491062
kwargs = _assemble_request_kwargs(
10501063
params=params,
10511064
headers=headers,
@@ -1058,7 +1071,7 @@ async def stream( # noqa: PLR0913 — mirrors httpx2 per-method signatures; kwa
10581071
files=files,
10591072
)
10601073

1061-
async with _httpx2_exception_mapper(), self._httpx2_client.stream(method, url, **kwargs) as response:
1074+
async with _httpx2_exception_mapper(), self._httpx2_client.stream(method, merged_url, **kwargs) as response:
10621075
if HTTPStatus.BAD_REQUEST <= response.status_code < 600: # noqa: PLR2004 — 600 is the synthetic upper bound for 5xx
10631076
cap = self._max_response_body_bytes
10641077
if cap is None:
@@ -1234,8 +1247,9 @@ def send_with_response(
12341247
return response, bound.decode(response)
12351248

12361249
def build_request(self, method: str, url: str, **kwargs: typing.Any) -> httpx2.Request:
1237-
"""Delegate request construction to the wrapped httpx2.Client."""
1238-
return self._httpx2_client.build_request(method, url, **kwargs)
1250+
"""Delegate request construction to the wrapped httpx2.Client, keeping the URL's own query."""
1251+
merged_url, params = _merge_url_query(url, kwargs.pop("params", None), self._httpx2_client.params)
1252+
return self._httpx2_client.build_request(method, merged_url, params=params, **kwargs)
12391253

12401254
def _prepare_request( # noqa: PLR0913 — mirrors httpx2 per-method signatures; kwargs-forwarding complexity is structural
12411255
self,
@@ -1252,6 +1266,7 @@ def _prepare_request( # noqa: PLR0913 — mirrors httpx2 per-method signatures;
12521266
data: typing.Any | None = None,
12531267
files: typing.Any | None = None,
12541268
) -> httpx2.Request:
1269+
merged_url, params = _merge_url_query(url, params, self._httpx2_client.params)
12551270
kwargs = _assemble_request_kwargs(
12561271
params=params,
12571272
headers=headers,
@@ -1263,7 +1278,7 @@ def _prepare_request( # noqa: PLR0913 — mirrors httpx2 per-method signatures;
12631278
data=data,
12641279
files=files,
12651280
)
1266-
request = self._httpx2_client.build_request(method, url, **kwargs)
1281+
request = self._httpx2_client.build_request(method, merged_url, **kwargs)
12671282
if _is_streaming_body_sync(content) or _is_streaming_body_sync(data) or _is_streaming_body_sync(files):
12681283
request.extensions[STREAMING_BODY_MARKER] = True
12691284
return request
@@ -2016,6 +2031,7 @@ def stream( # noqa: PLR0913 — mirrors httpx2 per-method signatures; kwargs-fo
20162031
Maps httpx2 exceptions raised during the request OR body consumption to
20172032
httpware exceptions via _httpx2_exception_mapper_sync.
20182033
"""
2034+
merged_url, params = _merge_url_query(url, params, self._httpx2_client.params)
20192035
kwargs = _assemble_request_kwargs(
20202036
params=params,
20212037
headers=headers,
@@ -2028,7 +2044,7 @@ def stream( # noqa: PLR0913 — mirrors httpx2 per-method signatures; kwargs-fo
20282044
files=files,
20292045
)
20302046

2031-
with _httpx2_exception_mapper_sync(), self._httpx2_client.stream(method, url, **kwargs) as response:
2047+
with _httpx2_exception_mapper_sync(), self._httpx2_client.stream(method, merged_url, **kwargs) as response:
20322048
if HTTPStatus.BAD_REQUEST <= response.status_code < 600: # noqa: PLR2004 — 600 is the synthetic upper bound for 5xx
20332049
cap = self._max_response_body_bytes
20342050
if cap is None:

‎tests/test_url_query_merge.py‎

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
"""The URL's own query string survives per-request and client-level `params`."""
2+
3+
from http import HTTPStatus
4+
5+
import httpx2
6+
import pytest
7+
8+
from httpware import AsyncClient, Client
9+
10+
11+
def _ok(request: httpx2.Request) -> httpx2.Response:
12+
return httpx2.Response(HTTPStatus.OK, request=request)
13+
14+
15+
def _async_client(captured: list[httpx2.Request], params: dict[str, str] | None = None) -> AsyncClient:
16+
def handler(request: httpx2.Request) -> httpx2.Response:
17+
captured.append(request)
18+
return _ok(request)
19+
20+
return AsyncClient(httpx2_client=httpx2.AsyncClient(transport=httpx2.MockTransport(handler), params=params))
21+
22+
23+
def _sync_client(captured: list[httpx2.Request], params: dict[str, str] | None = None) -> Client:
24+
def handler(request: httpx2.Request) -> httpx2.Response:
25+
captured.append(request)
26+
return _ok(request)
27+
28+
return Client(httpx2_client=httpx2.Client(transport=httpx2.MockTransport(handler), params=params))
29+
30+
31+
_CASES = [
32+
pytest.param("https://example.test/x?a=1", {"b": "2"}, {}, "a=1&b=2", id="url-query-plus-params"),
33+
pytest.param("https://example.test/x?a=1&b=1", {"b": "2"}, {}, "a=1&b=2", id="params-override-same-key"),
34+
pytest.param("https://example.test/x?a=1&a=2", {"b": "3"}, {}, "a=1&a=2&b=3", id="repeated-url-keys-kept"),
35+
pytest.param("https://example.test/x?a=1", {}, {}, "a=1", id="empty-params-keeps-url-query"),
36+
pytest.param("https://example.test/x?a=1", None, {"c": "3"}, "c=3&a=1", id="client-params-keep-url-query"),
37+
pytest.param(
38+
"https://example.test/x?a=1&c=1", {"b": "2"}, {"c": "3"}, "c=1&a=1&b=2", id="url-query-overrides-client"
39+
),
40+
pytest.param("https://example.test/x", {"b": "2"}, {"c": "3"}, "c=3&b=2", id="no-url-query-unchanged"),
41+
]
42+
43+
44+
@pytest.mark.parametrize(("url", "params", "client_params", "expected_query"), _CASES)
45+
def test_async_build_request_merges_url_query(
46+
url: str, params: dict[str, str] | None, client_params: dict[str, str], expected_query: str
47+
) -> None:
48+
client = _async_client([], params=client_params)
49+
assert client.build_request("GET", url, params=params).url.query == expected_query.encode()
50+
51+
52+
@pytest.mark.parametrize(("url", "params", "client_params", "expected_query"), _CASES)
53+
def test_sync_build_request_merges_url_query(
54+
url: str, params: dict[str, str] | None, client_params: dict[str, str], expected_query: str
55+
) -> None:
56+
client = _sync_client([], params=client_params)
57+
assert client.build_request("GET", url, params=params).url.query == expected_query.encode()
58+
59+
60+
@pytest.mark.parametrize(("url", "params", "client_params", "expected_query"), _CASES)
61+
async def test_async_get_merges_url_query(
62+
url: str, params: dict[str, str] | None, client_params: dict[str, str], expected_query: str
63+
) -> None:
64+
captured: list[httpx2.Request] = []
65+
await _async_client(captured, params=client_params).get(url, params=params)
66+
assert captured[0].url.query == expected_query.encode()
67+
68+
69+
@pytest.mark.parametrize(("url", "params", "client_params", "expected_query"), _CASES)
70+
def test_sync_get_merges_url_query(
71+
url: str, params: dict[str, str] | None, client_params: dict[str, str], expected_query: str
72+
) -> None:
73+
captured: list[httpx2.Request] = []
74+
_sync_client(captured, params=client_params).get(url, params=params)
75+
assert captured[0].url.query == expected_query.encode()
76+
77+
78+
@pytest.mark.parametrize(("url", "params", "client_params", "expected_query"), _CASES)
79+
async def test_async_stream_merges_url_query(
80+
url: str, params: dict[str, str] | None, client_params: dict[str, str], expected_query: str
81+
) -> None:
82+
captured: list[httpx2.Request] = []
83+
async with _async_client(captured, params=client_params).stream("GET", url, params=params):
84+
pass
85+
assert captured[0].url.query == expected_query.encode()
86+
87+
88+
@pytest.mark.parametrize(("url", "params", "client_params", "expected_query"), _CASES)
89+
def test_sync_stream_merges_url_query(
90+
url: str, params: dict[str, str] | None, client_params: dict[str, str], expected_query: str
91+
) -> None:
92+
captured: list[httpx2.Request] = []
93+
with _sync_client(captured, params=client_params).stream("GET", url, params=params):
94+
pass
95+
assert captured[0].url.query == expected_query.encode()
96+
97+
98+
def test_owned_client_relative_url_merges_with_base_url() -> None:
99+
client = Client(base_url="https://example.test/api", params={"c": "3"})
100+
request = client.build_request("GET", "items?a=1", params={"b": "2"})
101+
assert str(request.url) == "https://example.test/api/items?c=3&a=1&b=2"
102+
103+
104+
def test_url_query_without_params_is_left_verbatim() -> None:
105+
client = _sync_client([])
106+
url = "https://example.test/x?cursor=a%2Fb+c"
107+
assert str(client.build_request("GET", url).url) == url

0 commit comments

Comments
 (0)