Repository: encode/starlette

Issue:
JSONResponse Wrong Content-Length Value
### Describe the bug

`JSONResponse` populates the response header with a **non-zero** `Content-Length` whenever the response object is instantiated **without any content**.

### Checklist

- [X] The bug is reproducible against the latest release and/or `master`.
- [X] There are no similar issues or pull requests to fix it yet.

### To reproduce

```python
from starlette.datastructures import MutableHeaders
from starlette.responses import JSONResponse

r1 = JSONResponse('')
r1.headers

r2 = JSONResponse()
r2.headers
```

### Expected behavior

```python
( r1.headers == r2.headers
  and
  r1.headers == MutableHeaders({'content-length': '0', 'content-type': 'application/json'}) )
```

### Actual behavior

```python
( r1.headers != r2.headers
  and
  r1.headers == MutableHeaders({'content-length': '2', 'content-type': 'application/json'})
  and
  r2.headers == MutableHeaders({'content-length': '4', 'content-type': 'application/json'}))
```

### Debugging material

All that is needed is the currently stable release of `Starlette`, and the trusty `terminal`.

### Environment

- OS: same on all of Linux/Windows/macOS
- Python version: 3.7.x
- Starlette version: 0.14.x

### Additional context

I was trying to adhere to the HTTP ref spec when constructing responses to the OPTIONS and HEAD methods.



Discussion:
#### Nota Bene
While the bug still persists, there is a work around for the particular use case which prompted this bug report ...

Implement the [patch for an empty response class](https://github.com/encode/starlette/pull/1270), from pull request #1270, directly in your own backend code. Then 

```python
return EmptyResponse(headers={'Allow': '<comma separated list of allowed HTTP methods>'}, status_code=204)
```

@accredited-unicorn  Hi!

Looks like I have a suggestion why this is happens.

When you pass just quotes `('')` into `JSONResponse`, then length is counting against the quotes, and `2` looks like reasonable value.

But most interesting part is — Why it is `4` when you pass `None` or even do not specify value?

Let's take a look at the code:

Here is rendering of empty JSON Response 
https://github.com/encode/starlette/blob/42592d68e5d7fd044b79d7846eabe7f33961527c/starlette/responses.py#L156-L163

Looks good, but what happens if we try to encode `None` into UTF-8 with `json.dumps`? Let's try in your favourite REPL:
```
In [3]: import json

In [4]: json.dumps(None).encode("utf-8")
Out[4]: 'null'
```

That's why empty response weights `4`.

Personally I do not even sure this is a bug. I suggest such behaviour as expected when you deal with JSON.
Here's good related case at StackOverflow https://stackoverflow.com/questions/3548635/python-json-what-happened-to-none
And one more https://stackoverflow.com/questions/11410896/python-how-json-dumps-none-to-empty-string

So, `json.dumps` must rewrite `None` to valid `null`, that's why it happens.


> #### Nota Bene
> While the bug still persists, there is a work around for the particular use case which prompted this bug report ...
> 
> Implement the [patch for an empty response class](https://github.com/encode/starlette/pull/1270), from pull request #1270, directly in your own backend code. Then
> 
> ```python
> return EmptyResponse(headers={'Allow': '<comma separated list of allowed HTTP methods>'}, status_code=204)
> ```

I have just revised that pull request.  I eliminated the EmptyResponse class in favor of handling the special cases in the Response class.  The unit tests I wrote also check that JSONResponse does the right thing for 204, 205, and 304 status codes.


Resolving patch:
diff --git a/starlette/responses.py b/starlette/responses.py
--- a/starlette/responses.py
+++ b/starlette/responses.py
@@ -71,7 +71,11 @@ def init_headers(self, headers: typing.Mapping[str, str] = None) -> None:
             populate_content_type = b"content-type" not in keys
 
         body = getattr(self, "body", None)
-        if body is not None and populate_content_length:
+        if (
+            body is not None
+            and populate_content_length
+            and not (self.status_code < 200 or self.status_code in (204, 304))
+        ):
             content_length = str(len(body))
             raw_headers.append((b"content-length", content_length.encode("latin-1")))
 


Test patch:
diff --git a/tests/test_responses.py b/tests/test_responses.py
--- a/tests/test_responses.py
+++ b/tests/test_responses.py
@@ -333,6 +333,13 @@ def test_empty_response(test_client_factory):
     assert response.headers["content-length"] == "0"
 
 
+def test_empty_204_response(test_client_factory):
+    app = Response(status_code=204)
+    client: TestClient = test_client_factory(app)
+    response = client.get("/")
+    assert "content-length" not in response.headers
+
+
 def test_non_empty_response(test_client_factory):
     app = Response(content="hi")
     client: TestClient = test_client_factory(app)