Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@

::: httpx2.delete

::: httpx2.query

::: httpx2.stream

## `Client`
Expand All @@ -43,6 +45,7 @@
- put
- patch
- delete
- query
- stream
- sse
- build_request
Expand All @@ -66,6 +69,7 @@
- put
- patch
- delete
- query
- stream
- sse
- build_request
Expand Down
3 changes: 2 additions & 1 deletion docs/async.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ The request methods are all async, so you should use `response = await client.ge
* `AsyncClient.put(url, ...)`
* `AsyncClient.patch(url, ...)`
* `AsyncClient.delete(url, ...)`
* `AsyncClient.query(url, ...)`
* `AsyncClient.request(method, url, ...)`
* `AsyncClient.send(request, ...)`

Expand Down Expand Up @@ -191,4 +192,4 @@ anyio.run(main, backend='trio')

## Calling into Python Web Apps

For details on calling directly into ASGI applications, see [the `ASGITransport` docs](../advanced/transports#asgitransport).
For details on calling directly into ASGI applications, see [the `ASGITransport` docs](../advanced/transports#asgitransport).
1 change: 1 addition & 0 deletions src/httpx2/httpx2/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
"Proxy",
"ProxyError",
"put",
"query",
"QueryParams",
"ReadError",
"ReadTimeout",
Expand Down
45 changes: 44 additions & 1 deletion src/httpx2/httpx2/_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
"patch",
"post",
"put",
"query",
"request",
"stream",
"websocket",
Expand Down Expand Up @@ -67,7 +68,8 @@ def request(
"""Sends an HTTP request.

Parameters:
method: HTTP method for the new `Request` object: `GET`, `OPTIONS`, `HEAD`, `POST`, `PUT`, `PATCH`, or `DELETE`.
method: HTTP method for the new `Request` object: `GET`, `OPTIONS`, `HEAD`, `POST`, `PUT`, `PATCH`, `DELETE`,
or `QUERY`.
url: URL for the new `Request` object.
params: *(optional)* Query parameters to include in the URL, as a string, dictionary, or sequence of two-tuples.
content: *(optional)* Binary content to include in the body of the request, as bytes or a byte iterator.
Expand Down Expand Up @@ -435,6 +437,47 @@ def delete(
)


def query(
url: URL | str,
*,
content: RequestContent | None = None,
data: RequestData | None = None,
files: RequestFiles | None = None,
json: typing.Any | None = None,
params: QueryParamTypes | None = None,
headers: HeaderTypes | None = None,
cookies: CookieTypes | None = None,
auth: AuthTypes | None = None,
proxy: ProxyTypes | None = None,
follow_redirects: bool = False,
verify: ssl.SSLContext | str | bool = True,
timeout: TimeoutTypes = DEFAULT_TIMEOUT_CONFIG,
trust_env: bool = True,
) -> Response:
"""
Sends a `QUERY` request.

**Parameters**: See `httpx2.request`.
"""
return request(
"QUERY",
url,
content=content,
data=data,
files=files,
json=json,
params=params,
headers=headers,
cookies=cookies,
auth=auth,
proxy=proxy,
follow_redirects=follow_redirects,
verify=verify,
timeout=timeout,
trust_env=trust_env,
)


@contextmanager
def websocket(
url: URL | str,
Expand Down
78 changes: 77 additions & 1 deletion src/httpx2/httpx2/_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -488,7 +488,9 @@ def _redirect_method(self, request: Request, response: Response) -> str:

# Do what the browsers do, despite standards...
# Turn 302s into GETs.
if response.status_code == codes.FOUND and method != "HEAD":
# QUERY is excluded, per RFC 10008 Section 2.5.
# https://datatracker.ietf.org/doc/html/rfc10008#section-2.5
if response.status_code == codes.FOUND and method not in ("HEAD", "QUERY"):
method = "GET"

# If a POST is responded to with a 301, turn it into a GET.
Expand Down Expand Up @@ -1323,6 +1325,43 @@ def delete(
extensions=extensions,
)

def query(
self,
url: URL | str,
*,
content: RequestContent | None = None,
data: RequestData | None = None,
files: RequestFiles | None = None,
json: typing.Any | None = None,
params: QueryParamTypes | None = None,
headers: HeaderTypes | None = None,
cookies: CookieTypes | None = None,
auth: AuthTypes | UseClientDefault = USE_CLIENT_DEFAULT,
follow_redirects: bool | UseClientDefault = USE_CLIENT_DEFAULT,
timeout: TimeoutTypes | UseClientDefault = USE_CLIENT_DEFAULT,
extensions: RequestExtensions | None = None,
) -> Response:
"""
Send a `QUERY` request.

**Parameters**: See `httpx2.request`.
"""
return self.request(
"QUERY",
url,
content=content,
data=data,
files=files,
json=json,
params=params,
headers=headers,
cookies=cookies,
auth=auth,
follow_redirects=follow_redirects,
timeout=timeout,
extensions=extensions,
)

def close(self) -> None:
"""
Close transport and proxies.
Expand Down Expand Up @@ -2123,6 +2162,43 @@ async def delete(
extensions=extensions,
)

async def query(
self,
url: URL | str,
*,
content: RequestContent | None = None,
data: RequestData | None = None,
files: RequestFiles | None = None,
json: typing.Any | None = None,
params: QueryParamTypes | None = None,
headers: HeaderTypes | None = None,
cookies: CookieTypes | None = None,
auth: AuthTypes | UseClientDefault = USE_CLIENT_DEFAULT,
follow_redirects: bool | UseClientDefault = USE_CLIENT_DEFAULT,
timeout: TimeoutTypes | UseClientDefault = USE_CLIENT_DEFAULT,
extensions: RequestExtensions | None = None,
) -> Response:
"""
Send a `QUERY` request.

**Parameters**: See `httpx2.request`.
"""
return await self.request(
"QUERY",
url,
content=content,
data=data,
files=files,
json=json,
params=params,
headers=headers,
cookies=cookies,
auth=auth,
follow_redirects=follow_redirects,
timeout=timeout,
extensions=extensions,
)

async def aclose(self) -> None:
"""
Close transport and proxies.
Expand Down
4 changes: 2 additions & 2 deletions src/httpx2/httpx2/_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ def print_help() -> None:
table.add_column("Description")
table.add_row(
"-m, --method [cyan]METHOD",
"Request method, such as GET, POST, PUT, PATCH, DELETE, OPTIONS, HEAD.\n"
"Request method, such as GET, POST, PUT, PATCH, DELETE, OPTIONS, HEAD, QUERY.\n"
"[Default: GET, or POST if a request body is included]",
)
table.add_row(
Expand Down Expand Up @@ -297,7 +297,7 @@ def handle_help(
"method",
type=str,
help=(
"Request method, such as GET, POST, PUT, PATCH, DELETE, OPTIONS, HEAD. "
"Request method, such as GET, POST, PUT, PATCH, DELETE, OPTIONS, HEAD, QUERY. "
"[Default: GET, or POST if a request body is included]"
),
)
Expand Down
2 changes: 1 addition & 1 deletion src/httpx2/httpx2/_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -456,7 +456,7 @@ def _prepare(self, default_headers: dict[str, str]) -> None:

if not has_host and self.url.host:
auto_headers.append((b"Host", self.url.netloc))
if not has_content_length and self.method in ("POST", "PUT", "PATCH"):
if not has_content_length and self.method in ("POST", "PUT", "PATCH", "QUERY"):
auto_headers.append((b"Content-Length", b"0"))

self.headers = Headers(auto_headers + self.headers.raw)
Expand Down
7 changes: 7 additions & 0 deletions tests/httpx2/client/test_async_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,13 @@ async def test_delete(server: TestServer) -> None:
assert response.text == "Hello, world!"


@pytest.mark.anyio
async def test_query(server: TestServer) -> None:
async with httpx2.AsyncClient() as client:
response = await client.query(server.url, content=b"Hello, world!")
assert response.status_code == 200


@pytest.mark.anyio
async def test_100_continue(server: TestServer) -> None:
headers = {"Expect": "100-continue"}
Expand Down
7 changes: 7 additions & 0 deletions tests/httpx2/client/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,13 @@ def test_delete(server: TestServer) -> None:
assert response.reason_phrase == "OK"


def test_query(server: TestServer) -> None:
with httpx2.Client() as client:
response = client.query(server.url, content=b"Hello, world!")
assert response.status_code == 200
assert response.reason_phrase == "OK"


def test_base_url(server: TestServer) -> None:
base_url = server.url
with httpx2.Client(base_url=base_url) as client:
Expand Down
36 changes: 36 additions & 0 deletions tests/httpx2/client/test_redirects.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,11 @@ def redirects(request: httpx2.Request) -> httpx2.Response:
headers = {"location": "/redirect_body_target"}
return httpx2.Response(status_code, headers=headers)

elif request.url.path == "/redirect_found_body":
status_code = httpx2.codes.FOUND
headers = {"location": "/redirect_body_target"}
return httpx2.Response(status_code, headers=headers)

elif request.url.path == "/redirect_body_target":
data = {
"body": request.content.decode("ascii"),
Expand Down Expand Up @@ -320,6 +325,37 @@ def test_no_body_redirect() -> None:
assert "content-length" not in response.json()["headers"]


def test_query_body_redirect() -> None:
"""
A 302 redirect should preserve the method and body of a `QUERY` request.

https://datatracker.ietf.org/doc/html/rfc10008#section-2.5
"""
client = httpx2.Client(transport=httpx2.MockTransport(redirects))
url = "https://example.org/redirect_found_body"
content = b"Example request body"
response = client.query(url, content=content, follow_redirects=True)
assert response.url == "https://example.org/redirect_body_target"
assert response.request.method == "QUERY"
assert response.json()["body"] == "Example request body"
assert "content-length" in response.json()["headers"]


def test_query_no_body_redirect() -> None:
"""
A 303 redirect should convert a `QUERY` request to a `GET` request.

https://datatracker.ietf.org/doc/html/rfc10008#section-2.5
"""
client = httpx2.Client(transport=httpx2.MockTransport(redirects))
url = "https://example.org/redirect_no_body"
content = b"Example request body"
response = client.query(url, content=content, follow_redirects=True)
assert response.url == "https://example.org/redirect_body_target"
assert response.request.method == "GET"
assert response.json()["body"] == ""


def test_can_stream_if_no_redirect() -> None:
client = httpx2.Client(transport=httpx2.MockTransport(redirects))
url = "https://example.org/redirect_301"
Expand Down
6 changes: 6 additions & 0 deletions tests/httpx2/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,12 @@ def test_delete(server: TestServer) -> None:
assert response.reason_phrase == "OK"


def test_query(server: TestServer) -> None:
response = httpx2.query(server.url, content=b"Hello, world!")
assert response.status_code == 200
assert response.reason_phrase == "OK"


def test_stream(server: TestServer) -> None:
with httpx2.stream("GET", server.url) as response:
response.read()
Expand Down
5 changes: 5 additions & 0 deletions tests/httpx2/test_content.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ async def test_empty_content() -> None:
assert async_content == b""


def test_empty_query_content() -> None:
request = httpx2.Request("QUERY", url)
assert request.headers == {"Host": "www.example.com", "Content-Length": "0"}


@pytest.mark.anyio
async def test_bytes_content() -> None:
request = httpx2.Request(method, url, content=b"Hello, world!")
Expand Down
Loading