Repository: encode/starlette

Issue:
Starlette behind reverse proxy, url_for does not return externally valid urls
Suppose I have my application reverse proxied, such that http://my.domain/foo (e.g. Traefik or Nginx) has the upstream http://localhost:8000 (the Starlette app). `url_for` will generate urls relative to the upstream url. How do I make it produce urls relative to the external url (with prefix /foo)?

For other projects, e.g. Flask/Werkzeug, this can be handled by setting `SCRIPT_NAME` in the request environment (scope) if the 'X-Forwarded-Prefix' header is set. It does not look like there is an equivalent option in Starlette.

Am I missing something? Otherwise, consider this a feature request.

Discussion:

Patch:
diff --git a/starlette/datastructures.py b/starlette/datastructures.py
index 930c581..4080624 100644
--- a/starlette/datastructures.py
+++ b/starlette/datastructures.py
@@ -21,7 +21,7 @@ class URL:
             scheme = scope.get("scheme", "http")
             server = scope.get("server", None)
             path = scope.get("root_path", "") + scope["path"]
-            query_string = scope["query_string"]
+            query_string = scope.get("query_string", b"")
 
             host_header = None
             for key, value in scope["headers"]:
@@ -185,7 +185,8 @@ class URLPath(str):
         else:
             netloc = base_url.netloc
 
-        return str(URL(scheme=scheme, netloc=netloc, path=str(self)))
+        path = base_url.path.rstrip("/") + str(self)
+        return str(URL(scheme=scheme, netloc=netloc, path=path))
 
 
 class Secret:
diff --git a/starlette/requests.py b/starlette/requests.py
index a9955cf..c2f9f2b 100644
--- a/starlette/requests.py
+++ b/starlette/requests.py
@@ -56,6 +56,18 @@ class HTTPConnection(Mapping):
             self._url = URL(scope=self.scope)
         return self._url
 
+    @property
+    def base_url(self) -> URL:
+        if not hasattr(self, "_base_url"):
+            base_url_scope = dict(self.scope)
+            base_url_scope["path"] = "/"
+            base_url_scope["query_string"] = b""
+            base_url_scope["root_path"] = base_url_scope.get(
+                "app_root_path", base_url_scope.get("root_path", "")
+            )
+            self._base_url = URL(scope=base_url_scope)
+        return self._base_url
+
     @property
     def headers(self) -> Headers:
         if not hasattr(self, "_headers"):
@@ -123,7 +135,7 @@ class HTTPConnection(Mapping):
     def url_for(self, name: str, **path_params: typing.Any) -> str:
         router = self.scope["router"]
         url_path = router.url_path_for(name, **path_params)
-        return url_path.make_absolute_url(base_url=self.url)
+        return url_path.make_absolute_url(base_url=self.base_url)
 
 
 async def empty_receive() -> Message:
diff --git a/starlette/routing.py b/starlette/routing.py
index 48ed8ee..7a0d7ef 100644
--- a/starlette/routing.py
+++ b/starlette/routing.py
@@ -309,9 +309,11 @@ class Mount(BaseRoute):
                 matched_path = path[: -len(remaining_path)]
                 path_params = dict(scope.get("path_params", {}))
                 path_params.update(matched_params)
+                root_path = scope.get("root_path", "")
                 child_scope = {
                     "path_params": path_params,
-                    "root_path": scope.get("root_path", "") + matched_path,
+                    "app_root_path": scope.get("app_root_path", root_path),
+                    "root_path": root_path + matched_path,
                     "path": remaining_path,
                     "endpoint": self.app,
                 }


Test patch:
diff --git a/starlette/testclient.py b/starlette/testclient.py
index eb0e8be..471e623 100644
--- a/starlette/testclient.py
+++ b/starlette/testclient.py
@@ -88,9 +88,12 @@ class _WrapASGI2:
 
 
 class _ASGIAdapter(requests.adapters.HTTPAdapter):
-    def __init__(self, app: ASGI3App, raise_server_exceptions: bool = True) -> None:
+    def __init__(
+        self, app: ASGI3App, raise_server_exceptions: bool = True, root_path: str = ""
+    ) -> None:
         self.app = app
         self.raise_server_exceptions = raise_server_exceptions
+        self.root_path = root_path
 
     def send(  # type: ignore
         self, request: requests.PreparedRequest, *args: typing.Any, **kwargs: typing.Any
@@ -131,7 +134,7 @@ class _ASGIAdapter(requests.adapters.HTTPAdapter):
             scope = {
                 "type": "websocket",
                 "path": unquote(path),
-                "root_path": "",
+                "root_path": self.root_path,
                 "scheme": scheme,
                 "query_string": query.encode(),
                 "headers": headers,
@@ -147,7 +150,7 @@ class _ASGIAdapter(requests.adapters.HTTPAdapter):
             "http_version": "1.1",
             "method": request.method,
             "path": unquote(path),
-            "root_path": "",
+            "root_path": self.root_path,
             "scheme": scheme,
             "query_string": query.encode(),
             "headers": headers,
@@ -365,6 +368,7 @@ class TestClient(requests.Session):
         app: typing.Union[ASGI2App, ASGI3App],
         base_url: str = "http://testserver",
         raise_server_exceptions: bool = True,
+        root_path: str = "",
     ) -> None:
         super(TestClient, self).__init__()
         if _is_asgi3(app):
@@ -374,7 +378,9 @@ class TestClient(requests.Session):
             app = typing.cast(ASGI2App, app)
             asgi_app = _WrapASGI2(app)  #  type: ignore
         adapter = _ASGIAdapter(
-            asgi_app, raise_server_exceptions=raise_server_exceptions
+            asgi_app,
+            raise_server_exceptions=raise_server_exceptions,
+            root_path=root_path,
         )
         self.mount("http://", adapter)
         self.mount("https://", adapter)
diff --git a/tests/test_routing.py b/tests/test_routing.py
index 3c0c4cd..9723e46 100644
--- a/tests/test_routing.py
+++ b/tests/test_routing.py
@@ -1,5 +1,6 @@
 import pytest
 
+from starlette.applications import Starlette
 from starlette.responses import JSONResponse, PlainTextResponse, Response
 from starlette.routing import Host, Mount, NoMatchFound, Route, Router, WebSocketRoute
 from starlette.testclient import TestClient
@@ -164,12 +165,24 @@ def test_url_for():
         app.url_path_for("homepage").make_absolute_url(base_url="https://example.org")
         == "https://example.org/"
     )
+    assert (
+        app.url_path_for("homepage").make_absolute_url(
+            base_url="https://example.org/root_path/"
+        )
+        == "https://example.org/root_path/"
+    )
     assert (
         app.url_path_for("user", username="tomchristie").make_absolute_url(
             base_url="https://example.org"
         )
         == "https://example.org/users/tomchristie"
     )
+    assert (
+        app.url_path_for("user", username="tomchristie").make_absolute_url(
+            base_url="https://example.org/root_path/"
+        )
+        == "https://example.org/root_path/users/tomchristie"
+    )
     assert (
         app.url_path_for("websocket_endpoint").make_absolute_url(
             base_url="https://example.org"
@@ -353,3 +366,37 @@ def test_subdomain_reverse_urls():
         ).make_absolute_url("https://whatever")
         == "https://foo.example.org/homepage"
     )
+
+
+async def echo_urls(request):
+    return JSONResponse(
+        {
+            "index": request.url_for("index"),
+            "submount": request.url_for("mount:submount"),
+        }
+    )
+
+
+echo_url_routes = [
+    Route("/", echo_urls, name="index", methods=["GET"]),
+    Mount(
+        "/submount",
+        name="mount",
+        routes=[Route("/", echo_urls, name="submount", methods=["GET"])],
+    ),
+]
+
+
+def test_url_for_with_root_path():
+    app = Starlette(routes=echo_url_routes)
+    client = TestClient(app, base_url="https://www.example.org/", root_path="/sub_path")
+    response = client.get("/")
+    assert response.json() == {
+        "index": "https://www.example.org/sub_path/",
+        "submount": "https://www.example.org/sub_path/submount/",
+    }
+    response = client.get("/submount/")
+    assert response.json() == {
+        "index": "https://www.example.org/sub_path/",
+        "submount": "https://www.example.org/sub_path/submount/",
+    }