Repository: encode/starlette

Issue:
RedirectResponse call to `quote_plus` replaces spaces & `%20`'s with `+` character
### Checklist

<!-- Please make sure you check all these items before submitting your bug report. -->

- [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.

### Describe the bug

<!-- A clear and concise description of what the bug is. -->

`RedirectResponse` replaces url paths with spaces or `%20` with the `+` character. This happens as the `location` header is written after passing the url to `quote_plus`: https://github.com/encode/starlette/blob/master/starlette/responses.py#L181

If part of the path was a path param (with a space in it), this then modified those path params.

We currently encounter this with trailing slash redirects, which return a RedirectResponse.

### To reproduce

<!-- Provide a *minimal* example with steps to reproduce the bug locally.

NOTE: try to keep any external dependencies *at an absolute minimum*
(middleware, servers, proxies, certificates...).
In other words, remove anything that doesn't make the bug go away.
-->

```python
from starlette.applications import Starlette
from starlette.responses import JSONResponse
from starlette.routing import Route


async def homepage(request):
    name = request.path_params["name"]
    return JSONResponse({"hello": name})


app = Starlette(routes=[Route("/{name}/greeting", homepage)])


from starlette.testclient import TestClient

client = TestClient(app)


def test_redirect_homepage():
    name = "bob%20ross"
    url = f"{name}/greeting"
    for u in [url, url + "/"]:
        resp = client.get(u)
        assert url in str(resp.url)
```

### Expected behavior

<!-- A clear and concise description of what you expected to happen. -->

Expect that the location header would contain the url to redirect to without modification. If needed, it could replace the actual space character with `%20`, instead, but it's not clear to me that is necessary, after looking through the http specs.

This could be accomplished with swapping `urllib.quote_plus` for `urllib.quote` in `RedirectResponse`.

### Actual behavior

<!-- A clear and concise description of what actually happens. -->

Spaces are being replaced with the `+` character. 

<!-- Any tracebacks, screenshots, etc. that can help understanding the problem.

NOTE:
- Please list tracebacks in full (don't truncate them).
- Consider using `<details>` to make tracebacks/logs collapsible if they're very large (see https://gist.github.com/ericclemmons/b146fe5da72ca1f706b2ef72a20ac39d).
-->

### Environment

- OS: <!-- eg Linux/Windows/macOS. --> macOS
- Python version: <!-- eg 3.8.5 (get it with `$ python -V`). --> 3.8.8
- Starlette version: <!-- eg 0.13.8 (get it with `$ pip show starlette`). --> 0.13.6 (also separately tested 0.14.2)


Discussion:
The best places to start with this would be:

* Compare and contrast what we're doing here with alternative frameworks. What does Flask do? What does Django do? Link to the relevant places from this issue.
* Check the "blame" to see the history for that line. When was the quote introduced m, and is there any associated rationale for which characters are escaped in the Pull Request that introduced it?
okay, i'll work on getting this information

i did a quick look and flask does not seem to replace spaces for redirects but i'll and actually capture what the behavior is for the example above.

Flask:
[redirect](https://github.com/pallets/werkzeug/blob/master/src/werkzeug/utils.py#L528) calls [iri_to_uri](https://github.com/pallets/werkzeug/blob/c4d85cb9e0e78c2d5786baad649100d42526c399/src/werkzeug/urls.py#L753-L816) 
Django's [redirect](https://github.com/django/django/blob/main/django/http/response.py#L501) replaces spaces with `%20`'s in their [iri_to_uri](https://github.com/django/django/blob/main/django/utils/encoding.py#L100-L129) function

Their function is quite similar to the one in starlette actually, but they call quote instead of quote_plus.

This quote_plus was added in the initial implementation of RedirectResponse here: https://github.com/encode/starlette/pull/39/files I didn't see any discussion about why `quote_plus` instead of just `quote`

This is the specification that django docstring mentions for mapping an IRI to a URI: https://tools.ietf.org/html/rfc3987#page-10
If you'd like I can submit a Pull Request to make the url quoting the same as Django/flask?
@falkben a pull request would be appreciated

Resolving patch:
diff --git a/starlette/responses.py b/starlette/responses.py
--- a/starlette/responses.py
+++ b/starlette/responses.py
@@ -7,7 +7,7 @@
 import typing
 from email.utils import formatdate
 from mimetypes import guess_type as mimetypes_guess_type
-from urllib.parse import quote, quote_plus
+from urllib.parse import quote
 
 from starlette.background import BackgroundTask
 from starlette.concurrency import iterate_in_threadpool, run_until_first_complete
@@ -178,7 +178,7 @@ def __init__(
         super().__init__(
             content=b"", status_code=status_code, headers=headers, background=background
         )
-        self.headers["location"] = quote_plus(str(url), safe=":/%#?&=@[]!$&'()*+,;")
+        self.headers["location"] = quote(str(url), safe=":/%#?=@[]!$&'()*+,;")
 
 
 class StreamingResponse(Response):


Test patch:
diff --git a/tests/test_responses.py b/tests/test_responses.py
--- a/tests/test_responses.py
+++ b/tests/test_responses.py
@@ -60,6 +60,20 @@ async def app(scope, receive, send):
     assert response.url == "http://testserver/"
 
 
+def test_quoting_redirect_response():
+    async def app(scope, receive, send):
+        if scope["path"] == "/I ♥ Starlette/":
+            response = Response("hello, world", media_type="text/plain")
+        else:
+            response = RedirectResponse("/I ♥ Starlette/")
+        await response(scope, receive, send)
+
+    client = TestClient(app)
+    response = client.get("/redirect")
+    assert response.text == "hello, world"
+    assert response.url == "http://testserver/I%20%E2%99%A5%20Starlette/"
+
+
 def test_streaming_response():
     filled_by_bg_task = ""