Repository: encode/starlette

Issue:
[question] how to mount router into Starlette app? `app.mount('/', router)` does't work
Example:

```python
from starlette.applications import Starlette
from starlette.middleware.cors import CORSMiddleware
from starlette.requests import Request
from starlette.routing import Router, Route
import uvicorn

from views import FooView


app = Starlette(debug=True)
# need middleware, so I use Starlette app
app.add_middleware(
    CORSMiddleware, allow_origins=["*"], allow_headers=["*"], allow_methods=["*"]
)
router = Router([
    Route('/foo', endpoint=FooView),
])
app.mount('/', router)  # << doesn't work for some reason

if __name__ == '__main__':
    uvicorn.run(app, host='0.0.0.0', port=4000)
```


Discussion:
tomchristie: It'd be nice to also ensure that `url_path_for('mounted', path=...)` works as desired.

Patch:
diff --git a/starlette/routing.py b/starlette/routing.py
index 9f29d99..a2b851f 100644
--- a/starlette/routing.py
+++ b/starlette/routing.py
@@ -8,7 +8,7 @@ from enum import Enum
 from starlette.datastructures import URL, URLPath
 from starlette.exceptions import HTTPException
 from starlette.requests import Request
-from starlette.responses import PlainTextResponse
+from starlette.responses import PlainTextResponse, RedirectResponse
 from starlette.types import ASGIApp, ASGIInstance, Receive, Scope, Send
 from starlette.websockets import WebSocket, WebSocketClose
 
@@ -72,7 +72,9 @@ def get_name(endpoint: typing.Callable) -> str:
     return endpoint.__class__.__name__
 
 
-def replace_params(path: str, **path_params: str) -> typing.Tuple[str, dict]:
+def replace_params(
+    path: str, path_params: typing.Dict[str, str]
+) -> typing.Tuple[str, dict]:
     for key, value in list(path_params.items()):
         if "{" + key + "}" in path:
             path_params.pop(key)
@@ -95,14 +97,16 @@ class Route(BaseRoute):
     def __init__(
         self,
         path: str,
-        *,
         endpoint: typing.Callable,
+        *,
         methods: typing.List[str] = None,
+        name: str = None,
         include_in_schema: bool = True
     ) -> None:
+        assert path.startswith("/"), "Routed paths must always start '/'"
         self.path = path
         self.endpoint = endpoint
-        self.name = get_name(endpoint)
+        self.name = get_name(endpoint) if name is None else name
         self.include_in_schema = include_in_schema
 
         if inspect.isfunction(endpoint) or inspect.ismethod(endpoint):
@@ -137,7 +141,7 @@ class Route(BaseRoute):
     def url_path_for(self, name: str, **path_params: str) -> URLPath:
         if name != self.name or self.param_names != set(path_params.keys()):
             raise NoMatchFound()
-        path, remaining_params = replace_params(self.path, **path_params)
+        path, remaining_params = replace_params(self.path, path_params)
         assert not remaining_params
         return URLPath(path=path, protocol="http")
 
@@ -158,10 +162,13 @@ class Route(BaseRoute):
 
 
 class WebSocketRoute(BaseRoute):
-    def __init__(self, path: str, *, endpoint: typing.Callable) -> None:
+    def __init__(
+        self, path: str, endpoint: typing.Callable, *, name: str = None
+    ) -> None:
+        assert path.startswith("/"), "Routed paths must always start '/'"
         self.path = path
         self.endpoint = endpoint
-        self.name = get_name(endpoint)
+        self.name = get_name(endpoint) if name is None else name
 
         if inspect.isfunction(endpoint) or inspect.ismethod(endpoint):
             # Endpoint is function or method. Treat it as `func(websocket)`.
@@ -189,7 +196,7 @@ class WebSocketRoute(BaseRoute):
     def url_path_for(self, name: str, **path_params: str) -> URLPath:
         if name != self.name or self.param_names != set(path_params.keys()):
             raise NoMatchFound()
-        path, remaining_params = replace_params(self.path, **path_params)
+        path, remaining_params = replace_params(self.path, path_params)
         assert not remaining_params
         return URLPath(path=path, protocol="websocket")
 
@@ -205,12 +212,14 @@ class WebSocketRoute(BaseRoute):
 
 
 class Mount(BaseRoute):
-    def __init__(self, path: str, app: ASGIApp) -> None:
-        self.path = path
+    def __init__(self, path: str, app: ASGIApp, name: str = None) -> None:
+        assert path == "" or path.startswith("/"), "Routed paths must always start '/'"
+        self.path = path.rstrip("/")
         self.app = app
-        regex = "^" + path
+        regex = "^" + self.path + "(?P<path>/.*)$"
         regex = re.sub("{([a-zA-Z_][a-zA-Z0-9_]*)}", r"(?P<\1>[^/]*)", regex)
         self.path_regex = re.compile(regex)
+        self.name = name
 
     @property
     def routes(self) -> typing.List[BaseRoute]:
@@ -219,23 +228,40 @@ class Mount(BaseRoute):
     def matches(self, scope: Scope) -> typing.Tuple[Match, Scope]:
         match = self.path_regex.match(scope["path"])
         if match:
+            matched_params = match.groupdict()
+            matched_path = matched_params.pop("path")
             path_params = dict(scope.get("path_params", {}))
-            path_params.update(match.groupdict())
+            path_params.update(matched_params)
             child_scope = dict(scope)
             child_scope["path_params"] = path_params
-            child_scope["root_path"] = scope.get("root_path", "") + match.string
-            child_scope["path"] = scope["path"][match.span()[1] :]
+            child_scope["root_path"] = (
+                scope.get("root_path", "") + scope["path"][: -len(matched_path)]
+            )
+            child_scope["path"] = matched_path
             return Match.FULL, child_scope
         return Match.NONE, {}
 
     def url_path_for(self, name: str, **path_params: str) -> URLPath:
-        path, remaining_params = replace_params(self.path, **path_params)
-        for route in self.routes or []:
-            try:
-                url = route.url_path_for(name, **remaining_params)
-                return URLPath(path=path + str(url), protocol=url.protocol)
-            except NoMatchFound as exc:
-                pass
+        if self.name is not None and name == self.name and "path" in path_params:
+            # 'name' matches "<mount_name>".
+            path_params["path"] = path_params["path"].lstrip("/")
+            path, remaining_params = replace_params(self.path + "/{path}", path_params)
+            if not remaining_params:
+                return URLPath(path=path, protocol="http")
+        elif self.name is None or name.startswith(self.name + ":"):
+            if self.name is None:
+                # No mount name.
+                remaining_name = name
+            else:
+                # 'name' matches "<mount_name>:<child_name>".
+                remaining_name = name[len(self.name) + 1 :]
+            path, remaining_params = replace_params(self.path, path_params)
+            for route in self.routes or []:
+                try:
+                    url = route.url_path_for(remaining_name, **remaining_params)
+                    return URLPath(path=path + str(url), protocol=url.protocol)
+                except NoMatchFound as exc:
+                    pass
         raise NoMatchFound()
 
     def __call__(self, scope: Scope) -> ASGIInstance:
@@ -251,9 +277,13 @@ class Mount(BaseRoute):
 
 class Router:
     def __init__(
-        self, routes: typing.List[BaseRoute] = None, default: ASGIApp = None
+        self,
+        routes: typing.List[BaseRoute] = None,
+        redirect_slashes: bool = True,
+        default: ASGIApp = None,
     ) -> None:
         self.routes = [] if routes is None else routes
+        self.redirect_slashes = redirect_slashes
         self.default = self.not_found if default is None else default
 
     def mount(self, path: str, app: ASGIApp) -> None:
@@ -337,6 +367,17 @@ class Router:
 
         if partial is not None:
             return partial(partial_scope)
+
+        if self.redirect_slashes and not scope["path"].endswith("/"):
+            redirect_scope = dict(scope)
+            redirect_scope["path"] += "/"
+
+            for route in self.routes:
+                match, child_scope = route.matches(redirect_scope)
+                if match != Match.NONE:
+                    redirect_url = URL(scope=redirect_scope)
+                    return RedirectResponse(url=str(redirect_url))
+
         return self.default(scope)
 
     def __eq__(self, other: typing.Any) -> bool:


Test patch:
diff --git a/tests/test_routing.py b/tests/test_routing.py
index 6988b9f..ee2a7f7 100644
--- a/tests/test_routing.py
+++ b/tests/test_routing.py
@@ -1,7 +1,7 @@
 import pytest
 
 from starlette.exceptions import ExceptionMiddleware
-from starlette.responses import Response
+from starlette.responses import PlainTextResponse, Response
 from starlette.routing import Mount, NoMatchFound, Route, Router, WebSocketRoute
 from starlette.testclient import TestClient
 from starlette.websockets import WebSocket, WebSocketDisconnect
@@ -30,7 +30,7 @@ app = Router(
         Mount(
             "/users",
             app=Router(
-                [Route("", endpoint=users), Route("/{username}", endpoint=user)]
+                [Route("/", endpoint=users), Route("/{username}", endpoint=user)]
             ),
         ),
         Mount("/static", app=staticfiles),
@@ -176,3 +176,32 @@ def test_protocol_switch():
 
     with pytest.raises(WebSocketDisconnect):
         client.websocket_connect("/404")
+
+
+def ok(request):
+    return PlainTextResponse("OK")
+
+
+def test_mount_urls():
+    mounted = Router([Mount("/users", ok, name="users")])
+    client = TestClient(mounted)
+    assert client.get("/users").status_code == 200
+    assert client.get("/users").url == "http://testserver/users/"
+    assert client.get("/users/").status_code == 200
+    assert client.get("/users/a").status_code == 200
+    assert client.get("/usersa").status_code == 404
+
+
+def test_reverse_mount_urls():
+    mounted = Router([Mount("/users", ok, name="users")])
+    assert mounted.url_path_for("users", path="/a") == "/users/a"
+
+    users = Router([Route("/{username}", ok, name="user")])
+    mounted = Router([Mount("/{subpath}/users", users, name="users")])
+    assert (
+        mounted.url_path_for("users:user", subpath="test", username="tom")
+        == "/test/users/tom"
+    )
+    assert (
+        mounted.url_path_for("users", subpath="test", path="/tom") == "/test/users/tom"
+    )