Repository: encode/httpx

Issue:
Create a Client.build_request() method
See: https://github.com/encode/httpx/issues#issuecomment-515950955


Discussion:
@sethmlarson bad reference link

@sethmlarson I second @VeNoMouS — the link leads to the issues board. Any rememberance of where it should have gone to?
I don't recall actually. :( That's a shame!
Waaaah Waaaah waaaaaaaaaah
@sethmlarson Okay! Do you recall any more context on what `Client.build_request()` should do?
I think it was meant to combine all the `merge_*()` functions? Do you remember @tomchristie?
> I think it was meant to combine all the merge_*() functions?

Yup - we'd like to be able to do something like...

```python
request = client.build_request()
response = await client.send(request)
```



Resolving patch:
diff --git a/httpx/client.py b/httpx/client.py
--- a/httpx/client.py
+++ b/httpx/client.py
@@ -153,7 +153,7 @@ def merge_headers(
             return merged_headers
         return headers
 
-    async def send(
+    async def _get_response(
         self,
         request: AsyncRequest,
         *,
@@ -237,6 +237,33 @@ def _get_auth_middleware(
 
         return None
 
+    def build_request(
+        self,
+        method: str,
+        url: URLTypes,
+        *,
+        data: AsyncRequestData = None,
+        files: RequestFiles = None,
+        json: typing.Any = None,
+        params: QueryParamTypes = None,
+        headers: HeaderTypes = None,
+        cookies: CookieTypes = None,
+    ) -> AsyncRequest:
+        url = self.merge_url(url)
+        headers = self.merge_headers(headers)
+        cookies = self.merge_cookies(cookies)
+        request = AsyncRequest(
+            method,
+            url,
+            data=data,
+            files=files,
+            json=json,
+            params=params,
+            headers=headers,
+            cookies=cookies,
+        )
+        return request
+
 
 class AsyncClient(BaseClient):
     async def get(
@@ -492,12 +519,9 @@ async def request(
         timeout: TimeoutTypes = None,
         trust_env: bool = None,
     ) -> AsyncResponse:
-        url = self.merge_url(url)
-        headers = self.merge_headers(headers)
-        cookies = self.merge_cookies(cookies)
-        request = AsyncRequest(
-            method,
-            url,
+        request = self.build_request(
+            method=method,
+            url=url,
             data=data,
             files=files,
             json=json,
@@ -517,6 +541,29 @@ async def request(
         )
         return response
 
+    async def send(
+        self,
+        request: AsyncRequest,
+        *,
+        stream: bool = False,
+        auth: AuthTypes = None,
+        allow_redirects: bool = True,
+        verify: VerifyTypes = None,
+        cert: CertTypes = None,
+        timeout: TimeoutTypes = None,
+        trust_env: bool = None,
+    ) -> AsyncResponse:
+        return await self._get_response(
+            request=request,
+            stream=stream,
+            auth=auth,
+            allow_redirects=allow_redirects,
+            verify=verify,
+            cert=cert,
+            timeout=timeout,
+            trust_env=trust_env,
+        )
+
     async def close(self) -> None:
         await self.dispatch.close()
 
@@ -594,12 +641,9 @@ def request(
         timeout: TimeoutTypes = None,
         trust_env: bool = None,
     ) -> Response:
-        url = self.merge_url(url)
-        headers = self.merge_headers(headers)
-        cookies = self.merge_cookies(cookies)
-        request = AsyncRequest(
-            method,
-            url,
+        request = self.build_request(
+            method=method,
+            url=url,
             data=self._async_request_data(data),
             files=files,
             json=json,
@@ -607,9 +651,33 @@ def request(
             headers=headers,
             cookies=cookies,
         )
+        response = self.send(
+            request,
+            stream=stream,
+            auth=auth,
+            allow_redirects=allow_redirects,
+            verify=verify,
+            cert=cert,
+            timeout=timeout,
+            trust_env=trust_env,
+        )
+        return response
+
+    def send(
+        self,
+        request: AsyncRequest,
+        *,
+        stream: bool = False,
+        auth: AuthTypes = None,
+        allow_redirects: bool = True,
+        verify: VerifyTypes = None,
+        cert: CertTypes = None,
+        timeout: TimeoutTypes = None,
+        trust_env: bool = None,
+    ) -> Response:
         concurrency_backend = self.concurrency_backend
 
-        coroutine = self.send
+        coroutine = self._get_response
         args = [request]
         kwargs = {
             "stream": True,


Test patch:
diff --git a/tests/client/test_async_client.py b/tests/client/test_async_client.py
--- a/tests/client/test_async_client.py
+++ b/tests/client/test_async_client.py
@@ -14,6 +14,20 @@ async def test_get(server, backend):
     assert repr(response) == "<Response [200 OK]>"
 
 
+async def test_build_request(server, backend):
+    url = server.url.copy_with(path="/echo_headers")
+    headers = {"Custom-header": "value"}
+    async with httpx.AsyncClient(backend=backend) as client:
+        request = client.build_request("GET", url)
+        request.headers.update(headers)
+        response = await client.send(request)
+
+    assert response.status_code == 200
+    assert response.url == url
+
+    assert response.json()["Custom-header"] == "value"
+
+
 @pytest.mark.asyncio
 async def test_get_no_backend(server):
     """
diff --git a/tests/client/test_client.py b/tests/client/test_client.py
--- a/tests/client/test_client.py
+++ b/tests/client/test_client.py
@@ -19,6 +19,21 @@ def test_get(server):
     assert repr(response) == "<Response [200 OK]>"
 
 
+def test_build_request(server):
+    url = server.url.copy_with(path="/echo_headers")
+    headers = {"Custom-header": "value"}
+
+    with httpx.Client() as http:
+        request = http.build_request("GET", url)
+        request.headers.update(headers)
+        response = http.send(request)
+
+    assert response.status_code == 200
+    assert response.url == url
+
+    assert response.json()["Custom-header"] == "value"
+
+
 def test_post(server):
     with httpx.Client() as http:
         response = http.post(server.url, data=b"Hello, world!")
diff --git a/tests/conftest.py b/tests/conftest.py
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,4 +1,5 @@
 import asyncio
+import json
 import os
 import threading
 import time
@@ -53,6 +54,8 @@ async def app(scope, receive, send):
         await status_code(scope, receive, send)
     elif scope["path"].startswith("/echo_body"):
         await echo_body(scope, receive, send)
+    elif scope["path"].startswith("/echo_headers"):
+        await echo_headers(scope, receive, send)
     else:
         await hello_world(scope, receive, send)
 
@@ -111,6 +114,21 @@ async def echo_body(scope, receive, send):
     await send({"type": "http.response.body", "body": body})
 
 
+async def echo_headers(scope, receive, send):
+    body = {}
+    for name, value in scope.get("headers", []):
+        body[name.capitalize().decode()] = value.decode()
+
+    await send(
+        {
+            "type": "http.response.start",
+            "status": 200,
+            "headers": [[b"content-type", b"application/json"]],
+        }
+    )
+    await send({"type": "http.response.body", "body": json.dumps(body).encode()})
+
+
 class CAWithPKEncryption(trustme.CA):
     """Implementation of trustme.CA() that can emit
     private keys that are encrypted with a password.