Skip to content

Commit 05056f5

Browse files
authored
fix: reject a base_url that contains a query string (#147)
httpx2 appends the trailing slash and request path after the query, producing malformed URLs. Raise ValueError at construction, for both owned and caller-provided httpx2 clients.
1 parent 8878a3d commit 05056f5

3 files changed

Lines changed: 55 additions & 0 deletions

File tree

‎docs/index.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,8 @@ with Client(base_url="https://jsonplaceholder.typicode.com") as client:
6262
print(response.json())
6363
```
6464

65+
`base_url` must not contain a query string: constructing a client with one raises `ValueError`. Put query parameters shared by every request in `params=` instead.
66+
6567
Typed decoding via `response_model=` works the same way in both worlds:
6668

6769
```python

‎src/httpware/client.py‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,10 @@
3434
"httpx2_client=... cannot be combined with any of "
3535
f"{_FORWARDED_KWARG_NAMES}; configure the httpx2 client you pass instead."
3636
)
37+
_BASE_URL_QUERY_MESSAGE = (
38+
"base_url must not contain a query string: httpx2 appends request paths after it, "
39+
"producing malformed URLs. Pass the query as params=... instead."
40+
)
3741

3842

3943
def _build_default_decoders() -> tuple[ResponseDecoder, ...]:
@@ -81,6 +85,12 @@ def _validate_httpx2_client_conflict( # noqa: PLR0913 — 7 forwarded kwargs fr
8185
raise TypeError(_HTTPX2_CLIENT_CONFLICT_MESSAGE)
8286

8387

88+
def _reject_base_url_query(base_url: httpx2.URL | str) -> None:
89+
"""Raise ValueError if base_url carries a query string."""
90+
if httpx2.URL(base_url).query:
91+
raise ValueError(_BASE_URL_QUERY_MESSAGE)
92+
93+
8494
def _assemble_httpx2_client_kwargs( # noqa: PLR0913 — 7 forwarded kwargs from caller's constructor
8595
*,
8696
base_url: str,
@@ -191,9 +201,11 @@ def __init__( # noqa: PLR0913 — wide constructor is the cost of a single-call
191201
limits=limits,
192202
auth=auth,
193203
)
204+
_reject_base_url_query(httpx2_client.base_url)
194205
self._httpx2_client = httpx2_client
195206
self._owns_client = False
196207
else:
208+
_reject_base_url_query(base_url)
197209
kwargs = _assemble_httpx2_client_kwargs(
198210
base_url=base_url,
199211
headers=headers,
@@ -1143,9 +1155,11 @@ def __init__( # noqa: PLR0913 — wide constructor is the cost of a single-call
11431155
limits=limits,
11441156
auth=auth,
11451157
)
1158+
_reject_base_url_query(httpx2_client.base_url)
11461159
self._httpx2_client = httpx2_client
11471160
self._owns_client = False
11481161
else:
1162+
_reject_base_url_query(base_url)
11491163
kwargs = _assemble_httpx2_client_kwargs(
11501164
base_url=base_url,
11511165
headers=headers,

‎tests/test_base_url_query.py‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
"""A `base_url` carrying a query string is rejected at construction."""
2+
3+
import httpx2
4+
import pytest
5+
6+
from httpware import AsyncClient, Client
7+
8+
9+
_BASE_URL_WITH_QUERY = "https://example.test/api?key=secret"
10+
11+
12+
def test_async_client_rejects_base_url_with_query() -> None:
13+
with pytest.raises(ValueError, match="query string") as exc_info:
14+
AsyncClient(base_url=_BASE_URL_WITH_QUERY)
15+
assert "secret" not in str(exc_info.value)
16+
17+
18+
def test_sync_client_rejects_base_url_with_query() -> None:
19+
with pytest.raises(ValueError, match="query string") as exc_info:
20+
Client(base_url=_BASE_URL_WITH_QUERY)
21+
assert "secret" not in str(exc_info.value)
22+
23+
24+
def test_async_client_rejects_caller_owned_httpx2_client_base_url_with_query() -> None:
25+
with pytest.raises(ValueError, match="query string"):
26+
AsyncClient(httpx2_client=httpx2.AsyncClient(base_url=_BASE_URL_WITH_QUERY))
27+
28+
29+
def test_sync_client_rejects_caller_owned_httpx2_client_base_url_with_query() -> None:
30+
with pytest.raises(ValueError, match="query string"):
31+
Client(httpx2_client=httpx2.Client(base_url=_BASE_URL_WITH_QUERY))
32+
33+
34+
@pytest.mark.parametrize(
35+
"base_url", ["", "https://example.test", "https://example.test/api/", "https://example.test/#f"]
36+
)
37+
def test_base_url_without_query_is_accepted(base_url: str) -> None:
38+
assert Client(base_url=base_url)
39+
assert AsyncClient(base_url=base_url)

0 commit comments

Comments
 (0)