Repository: encode/httpx

Issue:
Supporting `Response(content=..., text=..., html=..., json=...)`
We’d like to support instantiating `Response` instances directly.

One really good example of why we might want to do that is for unit testing custom auth classes - https://github.com/encode/httpx/pull/1217#issuecomment-681825882

Currently `Response` instances are only ever created by the client itself, and...

* Have a mandatory `request=...` parameter.
* Have a mandatory `status_code` parameter.

The first things we should do here are:

* Make the `request` parameter become optional. Have a `.request` property that returns a `Request` instance or raises a RuntimeError if the instance is not set. Have a property setter for the `.request` property.
* Make the status code be enforced as kwarg-only, and make it optional, with a default of 200.

Once we’ve done those things we should add support for setting the content of a response, using `content=..., text=..., html=..., json=...` parameters, which mirror the request parameters of `data=..., files=..., json=...`, but differ because HTTP responses use different content types to HTTP requests.

The `encode(...)` method in `content_streams.py` would become `encode_request_body(data=..., files=..., json=..., boundary=...)`, and would be mirrored by a `encode_response_body(content=..., text=..., html=..., json=...)` method, which would use the following content streams:

* `content` - Support either bytes or byte iterator/aiterators. Setting `Content-Length` or `Transfer-Encoding: chunked`
* `text` - Set `Content-Type: text/plain; charset=“utf-8” `, and a `Content-Length` header.
* `html` - Set `Content-Type: text/html; charset=“utf-8”` and a `Content-Length` header.
* `json` - Set `Content-Type: application/json` and a `Content-Length` header.

We can tie in an `encoding=...` argument too, but let’s talk that through as a follow up.

What this would then all give us is the ability to create responses like so...

```python
response = httpx.Response(json={“hello”: “world”})
response = httpx.Response(html=“<body><h1>hello, world</h1></body>”})
response = httpx.Response(text=“Not found”, status_code=404)
response = httpx.Response(content=b’...’, headers=...)
```

Somewhat refs #1091


Discussion:
If any contributors want to take a go at this I'd suggest approaching it in parts...

* Issue a PR for making the `request=...` parameter not mandatory.
* Issue a PR making `status_code` a keyword-only argument, and defaulting it to 200.
* Issue a PR with the response body encoding handling (more complex than the other two.)
The issue is quite detailed thanks for that. 😄  I don't have much experience with `httpx` but would love to give it a try. 
Great. Definitely treat each of those items in isolation as separate PRs. 😀

Resolving patch:
diff --git a/httpx/_content.py b/httpx/_content.py
--- a/httpx/_content.py
+++ b/httpx/_content.py
@@ -1,6 +1,15 @@
 import inspect
-import typing
 from json import dumps as json_dumps
+from typing import (
+    Any,
+    AsyncIterable,
+    AsyncIterator,
+    Dict,
+    Iterable,
+    Iterator,
+    Tuple,
+    Union,
+)
 from urllib.parse import urlencode
 
 from ._exceptions import StreamConsumed
@@ -22,10 +31,10 @@ class PlainByteStream:
     def __init__(self, body: bytes) -> None:
         self._body = body
 
-    def __iter__(self) -> typing.Iterator[bytes]:
+    def __iter__(self) -> Iterator[bytes]:
         yield self._body
 
-    async def __aiter__(self) -> typing.AsyncIterator[bytes]:
+    async def __aiter__(self) -> AsyncIterator[bytes]:
         yield self._body
 
 
@@ -34,11 +43,11 @@ class GeneratorStream:
     Request content encoded as plain bytes, using an byte generator.
     """
 
-    def __init__(self, generator: typing.Iterable[bytes]) -> None:
+    def __init__(self, generator: Iterable[bytes]) -> None:
         self._generator = generator
         self._is_stream_consumed = False
 
-    def __iter__(self) -> typing.Iterator[bytes]:
+    def __iter__(self) -> Iterator[bytes]:
         if self._is_stream_consumed:
             raise StreamConsumed()
 
@@ -52,11 +61,11 @@ class AsyncGeneratorStream:
     Request content encoded as plain bytes, using an async byte iterator.
     """
 
-    def __init__(self, agenerator: typing.AsyncIterable[bytes]) -> None:
+    def __init__(self, agenerator: AsyncIterable[bytes]) -> None:
         self._agenerator = agenerator
         self._is_stream_consumed = False
 
-    async def __aiter__(self) -> typing.AsyncIterator[bytes]:
+    async def __aiter__(self) -> AsyncIterator[bytes]:
         if self._is_stream_consumed:
             raise StreamConsumed()
 
@@ -66,8 +75,8 @@ async def __aiter__(self) -> typing.AsyncIterator[bytes]:
 
 
 def encode_content(
-    content: typing.Union[str, bytes, ByteStream]
-) -> typing.Tuple[typing.Dict[str, str], ByteStream]:
+    content: Union[str, bytes, ByteStream]
+) -> Tuple[Dict[str, str], ByteStream]:
     if isinstance(content, (str, bytes)):
         body = content.encode("utf-8") if isinstance(content, str) else content
         content_length = str(len(body))
@@ -75,7 +84,7 @@ def encode_content(
         stream = PlainByteStream(body)
         return headers, stream
 
-    elif isinstance(content, (typing.Iterable, typing.AsyncIterable)):
+    elif isinstance(content, (Iterable, AsyncIterable)):
         headers = {"Transfer-Encoding": "chunked"}
 
         # Generators should be wrapped in GeneratorStream/AsyncGeneratorStream
@@ -96,7 +105,7 @@ def encode_content(
 
 def encode_urlencoded_data(
     data: dict,
-) -> typing.Tuple[typing.Dict[str, str], ByteStream]:
+) -> Tuple[Dict[str, str], ByteStream]:
     body = urlencode(data, doseq=True).encode("utf-8")
     content_length = str(len(body))
     content_type = "application/x-www-form-urlencoded"
@@ -106,13 +115,29 @@ def encode_urlencoded_data(
 
 def encode_multipart_data(
     data: dict, files: RequestFiles, boundary: bytes = None
-) -> typing.Tuple[typing.Dict[str, str], ByteStream]:
+) -> Tuple[Dict[str, str], ByteStream]:
     stream = MultipartStream(data=data, files=files, boundary=boundary)
     headers = stream.get_headers()
     return headers, stream
 
 
-def encode_json(json: typing.Any) -> typing.Tuple[typing.Dict[str, str], ByteStream]:
+def encode_text(text: str) -> Tuple[Dict[str, str], ByteStream]:
+    body = text.encode("utf-8")
+    content_length = str(len(body))
+    content_type = "text/plain; charset=utf-8"
+    headers = {"Content-Length": content_length, "Content-Type": content_type}
+    return headers, PlainByteStream(body)
+
+
+def encode_html(html: str) -> Tuple[Dict[str, str], ByteStream]:
+    body = html.encode("utf-8")
+    content_length = str(len(body))
+    content_type = "text/html; charset=utf-8"
+    headers = {"Content-Length": content_length, "Content-Type": content_type}
+    return headers, PlainByteStream(body)
+
+
+def encode_json(json: Any) -> Tuple[Dict[str, str], ByteStream]:
     body = json_dumps(json).encode("utf-8")
     content_length = str(len(body))
     content_type = "application/json"
@@ -124,9 +149,9 @@ def encode_request(
     content: RequestContent = None,
     data: RequestData = None,
     files: RequestFiles = None,
-    json: typing.Any = None,
+    json: Any = None,
     boundary: bytes = None,
-) -> typing.Tuple[typing.Dict[str, str], ByteStream]:
+) -> Tuple[Dict[str, str], ByteStream]:
     """
     Handles encoding the given `content`, `data`, `files`, and `json`,
     returning a two-tuple of (<headers>, <stream>).
@@ -155,12 +180,21 @@ def encode_request(
 
 def encode_response(
     content: ResponseContent = None,
-) -> typing.Tuple[typing.Dict[str, str], ByteStream]:
+    text: str = None,
+    html: str = None,
+    json: Any = None,
+) -> Tuple[Dict[str, str], ByteStream]:
     """
     Handles encoding the given `content`, returning a two-tuple of
     (<headers>, <stream>).
     """
     if content is not None:
         return encode_content(content)
+    elif text is not None:
+        return encode_text(text)
+    elif html is not None:
+        return encode_html(html)
+    elif json is not None:
+        return encode_json(json)
 
     return {}, PlainByteStream(b"")
diff --git a/httpx/_models.py b/httpx/_models.py
--- a/httpx/_models.py
+++ b/httpx/_models.py
@@ -704,11 +704,14 @@ def __init__(
         self,
         status_code: int,
         *,
-        request: Request = None,
-        http_version: str = None,
         headers: HeaderTypes = None,
         content: ResponseContent = None,
+        text: str = None,
+        html: str = None,
+        json: typing.Any = None,
         stream: ByteStream = None,
+        http_version: str = None,
+        request: Request = None,
         history: typing.List["Response"] = None,
         on_close: typing.Callable = None,
     ):
@@ -740,7 +743,7 @@ def __init__(
             # from the transport API.
             self.stream = stream
         else:
-            headers, stream = encode_response(content)
+            headers, stream = encode_response(content, text, html, json)
             self._prepare(headers)
             self.stream = stream
             if content is None or isinstance(content, bytes):


Test patch:
diff --git a/tests/client/test_auth.py b/tests/client/test_auth.py
--- a/tests/client/test_auth.py
+++ b/tests/client/test_auth.py
@@ -5,7 +5,6 @@
 """
 import asyncio
 import hashlib
-import json
 import os
 import threading
 import typing
@@ -27,8 +26,7 @@ def __init__(self, auth_header: str = "", status_code: int = 200) -> None:
     def __call__(self, request: httpx.Request) -> httpx.Response:
         headers = {"www-authenticate": self.auth_header} if self.auth_header else {}
         data = {"auth": request.headers.get("Authorization")}
-        content = json.dumps(data).encode("utf-8")
-        return httpx.Response(self.status_code, headers=headers, content=content)
+        return httpx.Response(self.status_code, headers=headers, json=data)
 
 
 class DigestApp:
@@ -50,8 +48,7 @@ def __call__(self, request: httpx.Request) -> httpx.Response:
             return self.challenge_send(request)
 
         data = {"auth": request.headers.get("Authorization")}
-        content = json.dumps(data).encode("utf-8")
-        return httpx.Response(200, content=content)
+        return httpx.Response(200, json=data)
 
     def challenge_send(self, request: httpx.Request) -> httpx.Response:
         self._response_count += 1
diff --git a/tests/client/test_cookies.py b/tests/client/test_cookies.py
--- a/tests/client/test_cookies.py
+++ b/tests/client/test_cookies.py
@@ -1,4 +1,3 @@
-import json
 from http.cookiejar import Cookie, CookieJar
 
 import httpx
@@ -8,8 +7,7 @@
 def get_and_set_cookies(request: httpx.Request) -> httpx.Response:
     if request.url.path == "/echo_cookies":
         data = {"cookies": request.headers.get("cookie")}
-        content = json.dumps(data).encode("utf-8")
-        return httpx.Response(200, content=content)
+        return httpx.Response(200, json=data)
     elif request.url.path == "/set_cookie":
         return httpx.Response(200, headers={"set-cookie": "example-name=example-value"})
     else:
diff --git a/tests/client/test_headers.py b/tests/client/test_headers.py
--- a/tests/client/test_headers.py
+++ b/tests/client/test_headers.py
@@ -1,7 +1,5 @@
 #!/usr/bin/env python3
 
-import json
-
 import pytest
 
 import httpx
@@ -10,8 +8,7 @@
 
 def echo_headers(request: httpx.Request) -> httpx.Response:
     data = {"headers": dict(request.headers)}
-    content = json.dumps(data).encode("utf-8")
-    return httpx.Response(200, content=content)
+    return httpx.Response(200, json=data)
 
 
 def test_client_header():
diff --git a/tests/client/test_queryparams.py b/tests/client/test_queryparams.py
--- a/tests/client/test_queryparams.py
+++ b/tests/client/test_queryparams.py
@@ -3,7 +3,7 @@
 
 
 def hello_world(request: httpx.Request) -> httpx.Response:
-    return httpx.Response(200, content=b"Hello, world")
+    return httpx.Response(200, text="Hello, world")
 
 
 def test_client_queryparams():
diff --git a/tests/client/test_redirects.py b/tests/client/test_redirects.py
--- a/tests/client/test_redirects.py
+++ b/tests/client/test_redirects.py
@@ -1,5 +1,3 @@
-import json
-
 import httpcore
 import pytest
 
@@ -78,8 +76,11 @@ def redirects(request: httpx.Request) -> httpx.Response:
 
     elif request.url.path == "/cross_domain_target":
         status_code = httpx.codes.OK
-        content = json.dumps({"headers": dict(request.headers)}).encode("utf-8")
-        return httpx.Response(status_code, content=content)
+        data = {
+            "body": request.content.decode("ascii"),
+            "headers": dict(request.headers),
+        }
+        return httpx.Response(status_code, json=data)
 
     elif request.url.path == "/redirect_body":
         status_code = httpx.codes.PERMANENT_REDIRECT
@@ -92,10 +93,11 @@ def redirects(request: httpx.Request) -> httpx.Response:
         return httpx.Response(status_code, headers=headers)
 
     elif request.url.path == "/redirect_body_target":
-        content = json.dumps(
-            {"body": request.content.decode("ascii"), "headers": dict(request.headers)}
-        ).encode("utf-8")
-        return httpx.Response(200, content=content)
+        data = {
+            "body": request.content.decode("ascii"),
+            "headers": dict(request.headers),
+        }
+        return httpx.Response(200, json=data)
 
     elif request.url.path == "/cross_subdomain":
         if request.headers["Host"] != "www.example.org":
@@ -103,7 +105,7 @@ def redirects(request: httpx.Request) -> httpx.Response:
             headers = {"location": "https://www.example.org/cross_subdomain"}
             return httpx.Response(status_code, headers=headers)
         else:
-            return httpx.Response(200, content=b"Hello, world!")
+            return httpx.Response(200, text="Hello, world!")
 
     elif request.url.path == "/redirect_custom_scheme":
         status_code = httpx.codes.MOVED_PERMANENTLY
@@ -113,7 +115,7 @@ def redirects(request: httpx.Request) -> httpx.Response:
     if request.method == "HEAD":
         return httpx.Response(200)
 
-    return httpx.Response(200, content=b"Hello, world!")
+    return httpx.Response(200, html="<html><body>Hello, world!</body></html>")
 
 
 def test_no_redirect():
diff --git a/tests/models/test_responses.py b/tests/models/test_responses.py
--- a/tests/models/test_responses.py
+++ b/tests/models/test_responses.py
@@ -38,6 +38,48 @@ def test_response():
     assert not response.is_error
 
 
+def test_response_text():
+    response = httpx.Response(200, text="Hello, world!")
+
+    assert response.status_code == 200
+    assert response.reason_phrase == "OK"
+    assert response.text == "Hello, world!"
+    assert response.headers == httpx.Headers(
+        {
+            "Content-Length": "13",
+            "Content-Type": "text/plain; charset=utf-8",
+        }
+    )
+
+
+def test_response_html():
+    response = httpx.Response(200, html="<html><body>Hello, world!</html></body>")
+
+    assert response.status_code == 200
+    assert response.reason_phrase == "OK"
+    assert response.text == "<html><body>Hello, world!</html></body>"
+    assert response.headers == httpx.Headers(
+        {
+            "Content-Length": "39",
+            "Content-Type": "text/html; charset=utf-8",
+        }
+    )
+
+
+def test_response_json():
+    response = httpx.Response(200, json={"hello": "world"})
+
+    assert response.status_code == 200
+    assert response.reason_phrase == "OK"
+    assert response.json() == {"hello": "world"}
+    assert response.headers == httpx.Headers(
+        {
+            "Content-Length": "18",
+            "Content-Type": "application/json",
+        }
+    )
+
+
 def test_raise_for_status():
     request = httpx.Request("GET", "https://example.org")