diff --git a/docs/api.md b/docs/api.md index 24b30300..254106bb 100644 --- a/docs/api.md +++ b/docs/api.md @@ -24,6 +24,8 @@ ::: httpx2.delete +::: httpx2.query + ::: httpx2.stream ## `Client` @@ -43,6 +45,7 @@ - put - patch - delete + - query - stream - sse - build_request @@ -66,6 +69,7 @@ - put - patch - delete + - query - stream - sse - build_request diff --git a/docs/async.md b/docs/async.md index a25a3eb6..72dc4ad7 100644 --- a/docs/async.md +++ b/docs/async.md @@ -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, ...)` @@ -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). \ No newline at end of file +For details on calling directly into ASGI applications, see [the `ASGITransport` docs](../advanced/transports#asgitransport). diff --git a/src/httpx2/httpx2/__init__.py b/src/httpx2/httpx2/__init__.py index 12e223ba..987fde68 100644 --- a/src/httpx2/httpx2/__init__.py +++ b/src/httpx2/httpx2/__init__.py @@ -58,6 +58,7 @@ "Proxy", "ProxyError", "put", + "query", "QueryParams", "ReadError", "ReadTimeout", diff --git a/src/httpx2/httpx2/_api.py b/src/httpx2/httpx2/_api.py index e233cc80..83cfe328 100644 --- a/src/httpx2/httpx2/_api.py +++ b/src/httpx2/httpx2/_api.py @@ -40,6 +40,7 @@ "patch", "post", "put", + "query", "request", "stream", "websocket", @@ -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. @@ -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, diff --git a/src/httpx2/httpx2/_client.py b/src/httpx2/httpx2/_client.py index ed08f388..ae73434e 100644 --- a/src/httpx2/httpx2/_client.py +++ b/src/httpx2/httpx2/_client.py @@ -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. @@ -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. @@ -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. diff --git a/src/httpx2/httpx2/_main.py b/src/httpx2/httpx2/_main.py index f5b8dbfc..97f23417 100644 --- a/src/httpx2/httpx2/_main.py +++ b/src/httpx2/httpx2/_main.py @@ -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( @@ -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]" ), ) diff --git a/src/httpx2/httpx2/_models.py b/src/httpx2/httpx2/_models.py index 4c860b04..73613ad8 100644 --- a/src/httpx2/httpx2/_models.py +++ b/src/httpx2/httpx2/_models.py @@ -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) diff --git a/tests/httpx2/client/test_async_client.py b/tests/httpx2/client/test_async_client.py index 667a6c39..710bf020 100644 --- a/tests/httpx2/client/test_async_client.py +++ b/tests/httpx2/client/test_async_client.py @@ -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"} diff --git a/tests/httpx2/client/test_client.py b/tests/httpx2/client/test_client.py index 83de5c3d..6fad1b66 100644 --- a/tests/httpx2/client/test_client.py +++ b/tests/httpx2/client/test_client.py @@ -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: diff --git a/tests/httpx2/client/test_redirects.py b/tests/httpx2/client/test_redirects.py index 16613c2f..0dee968c 100644 --- a/tests/httpx2/client/test_redirects.py +++ b/tests/httpx2/client/test_redirects.py @@ -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"), @@ -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" diff --git a/tests/httpx2/test_api.py b/tests/httpx2/test_api.py index 01b20fe5..13b51406 100644 --- a/tests/httpx2/test_api.py +++ b/tests/httpx2/test_api.py @@ -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() diff --git a/tests/httpx2/test_content.py b/tests/httpx2/test_content.py index 426705cf..f39c3217 100644 --- a/tests/httpx2/test_content.py +++ b/tests/httpx2/test_content.py @@ -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!")