Repository: encode/starlette

Issue:
Routing interfaces for `Router` and `Starlette` should match.
We should probably hide away `Path` and `Include` as implementation details for now, and instead focus on making the app and router interfaces match up.

Eg:

```python
app = Router()

@app.route('/')
async def homepage(...):
    ...
```

or...

```python
app = Router()

app.mount('/admin', AdminApp)
app.mount('/articles', BlogArticles)
```

This'll mean that a `Router` instance strictly gives you a subset of the functionality that a `Starlette` instance offers.

Refs #122 


Discussion:
I'll go ahead and work on implementing this when I get a chance.

Initial thoughts on tackling this would be to transfer the function logic into the `Router` class. `Starlette` can then wrap them in it's interface like so:
```python
def add_route(...)
    self.router.add_route(path, route, methods=methods)

def add_websocket_route(...):
    self.router.add_websocket_route(path, route)
```

Resolving patch:
diff --git a/starlette/applications.py b/starlette/applications.py
--- a/starlette/applications.py
+++ b/starlette/applications.py
@@ -1,62 +1,17 @@
-import asyncio
-import inspect
 import typing
-from concurrent.futures import ThreadPoolExecutor
 
 from starlette.exceptions import ExceptionMiddleware
 from starlette.lifespan import LifespanHandler
-from starlette.graphql import GraphQLApp
-from starlette.requests import Request
-from starlette.routing import Path, PathPrefix, Router
-from starlette.types import ASGIApp, ASGIInstance, Receive, Scope, Send
-from starlette.websockets import WebSocket
-
-
-def request_response(func: typing.Callable) -> ASGIApp:
-    """
-    Takes a function or coroutine `func(request, **kwargs) -> response`,
-    and returns an ASGI application.
-    """
-    is_coroutine = asyncio.iscoroutinefunction(func)
-
-    def app(scope: Scope) -> ASGIInstance:
-        async def awaitable(receive: Receive, send: Send) -> None:
-            request = Request(scope, receive=receive)
-            kwargs = scope.get("kwargs", {})
-            if is_coroutine:
-                response = await func(request, **kwargs)
-            else:
-                response = func(request, **kwargs)
-            await response(receive, send)
-
-        return awaitable
-
-    return app
-
-
-def websocket_session(func: typing.Callable) -> ASGIApp:
-    """
-    Takes a coroutine `func(session, **kwargs)`, and returns an ASGI application.
-    """
-
-    def app(scope: Scope) -> ASGIInstance:
-        async def awaitable(receive: Receive, send: Send) -> None:
-            session = WebSocket(scope, receive=receive, send=send)
-            kwargs = scope.get("kwargs", {})
-            await func(session, **kwargs)
-
-        return awaitable
-
-    return app
+from starlette.routing import Router
+from starlette.types import ASGIApp, ASGIInstance, Scope
 
 
 class Starlette:
     def __init__(self, debug: bool = False) -> None:
-        self.router = Router(routes=[])
+        self.router = Router()
         self.lifespan_handler = LifespanHandler()
         self.app = self.router
         self.exception_middleware = ExceptionMiddleware(self.router, debug=debug)
-        self.executor = ThreadPoolExecutor()
 
     @property
     def debug(self) -> bool:
@@ -72,8 +27,7 @@ def on_event(self, event_type: str) -> typing.Callable:
     def mount(
         self, path: str, app: ASGIApp, methods: typing.Sequence[str] = None
     ) -> None:
-        prefix = PathPrefix(path, app=app, methods=methods)
-        self.router.routes.append(prefix)
+        self.router.mount(path, app=app, methods=methods)
 
     def add_middleware(self, middleware_class: type, **kwargs: typing.Any) -> None:
         self.exception_middleware.app = middleware_class(self.app, **kwargs)
@@ -87,26 +41,13 @@ def add_event_handler(self, event_type: str, func: typing.Callable) -> None:
     def add_route(
         self, path: str, route: typing.Callable, methods: typing.Sequence[str] = None
     ) -> None:
-        if not inspect.isclass(route):
-            route = request_response(route)
-            if methods is None:
-                methods = ("GET",)
+        self.router.add_route(path, route, methods=methods)
 
-        instance = Path(path, route, protocol="http", methods=methods)
-        self.router.routes.append(instance)
-
-    def add_graphql_route(
-        self, path: str, schema: typing.Any, executor: typing.Any = None
-    ) -> None:
-        route = GraphQLApp(schema=schema, executor=executor)
-        self.add_route(path, route, methods=["GET", "POST"])
+    def add_graphql_route(self, path: str, schema: typing.Any) -> None:
+        self.router.add_graphql_route(path, schema)
 
     def add_websocket_route(self, path: str, route: typing.Callable) -> None:
-        if not inspect.isclass(route):
-            route = websocket_session(route)
-
-        instance = Path(path, route, protocol="websocket")
-        self.router.routes.append(instance)
+        self.router.add_websocket_route(path, route)
 
     def exception_handler(self, exc_class: type) -> typing.Callable:
         def decorator(func: typing.Callable) -> typing.Callable:
@@ -117,21 +58,20 @@ def decorator(func: typing.Callable) -> typing.Callable:
 
     def route(self, path: str, methods: typing.Sequence[str] = None) -> typing.Callable:
         def decorator(func: typing.Callable) -> typing.Callable:
-            self.add_route(path, func, methods=methods)
+            self.router.add_route(path, func, methods=methods)
             return func
 
         return decorator
 
     def websocket_route(self, path: str) -> typing.Callable:
         def decorator(func: typing.Callable) -> typing.Callable:
-            self.add_websocket_route(path, func)
+            self.router.add_websocket_route(path, func)
             return func
 
         return decorator
 
     def __call__(self, scope: Scope) -> ASGIInstance:
         scope["app"] = self
-        scope["executor"] = self.executor
         if scope["type"] == "lifespan":
             return self.lifespan_handler(scope)
         return self.exception_middleware(scope)
diff --git a/starlette/routing.py b/starlette/routing.py
--- a/starlette/routing.py
+++ b/starlette/routing.py
@@ -1,9 +1,53 @@
-from starlette.exceptions import HTTPException
-from starlette.responses import PlainTextResponse
-from starlette.types import Scope, ASGIApp, ASGIInstance
-from starlette.websockets import WebSocketClose
 import re
 import typing
+import inspect
+import asyncio
+from concurrent.futures import ThreadPoolExecutor
+
+from starlette.requests import Request
+from starlette.exceptions import HTTPException
+from starlette.responses import PlainTextResponse
+from starlette.types import Scope, ASGIApp, ASGIInstance, Send, Receive
+from starlette.websockets import WebSocket, WebSocketClose
+from starlette.graphql import GraphQLApp
+
+
+def request_response(func: typing.Callable) -> ASGIApp:
+    """
+    Takes a function or coroutine `func(request, **kwargs) -> response`,
+    and returns an ASGI application.
+    """
+    is_coroutine = asyncio.iscoroutinefunction(func)
+
+    def app(scope: Scope) -> ASGIInstance:
+        async def awaitable(receive: Receive, send: Send) -> None:
+            request = Request(scope, receive=receive)
+            kwargs = scope.get("kwargs", {})
+            if is_coroutine:
+                response = await func(request, **kwargs)
+            else:
+                response = func(request, **kwargs)
+            await response(receive, send)
+
+        return awaitable
+
+    return app
+
+
+def websocket_session(func: typing.Callable) -> ASGIApp:
+    """
+    Takes a coroutine `func(session, **kwargs)`, and returns an ASGI application.
+    """
+
+    def app(scope: Scope) -> ASGIInstance:
+        async def awaitable(receive: Receive, send: Send) -> None:
+            session = WebSocket(scope, receive=receive, send=send)
+            kwargs = scope.get("kwargs", {})
+            await func(session, **kwargs)
+
+        return awaitable
+
+    return app
 
 
 class Route:
@@ -81,12 +125,60 @@ def __call__(self, scope: Scope) -> ASGIInstance:
 
 
 class Router:
-    def __init__(self, routes: typing.List[Route], default: ASGIApp = None) -> None:
-        self.routes = routes
+    def __init__(
+        self, routes: typing.List[Route] = None, default: ASGIApp = None
+    ) -> None:
+        self.routes = [] if routes is None else routes
         self.default = self.not_found if default is None else default
+        self.executor = ThreadPoolExecutor()
+
+    def mount(
+        self, path: str, app: ASGIApp, methods: typing.Sequence[str] = None
+    ) -> None:
+        prefix = PathPrefix(path, app=app, methods=methods)
+        self.routes.append(prefix)
+
+    def add_route(
+        self, path: str, route: typing.Callable, methods: typing.Sequence[str] = None
+    ) -> None:
+        if not inspect.isclass(route):
+            route = request_response(route)
+            if methods is None:
+                methods = ("GET",)
+
+        instance = Path(path, route, protocol="http", methods=methods)
+        self.routes.append(instance)
+
+    def add_graphql_route(
+        self, path: str, schema: typing.Any, executor: typing.Any = None
+    ) -> None:
+        route = GraphQLApp(schema=schema, executor=executor)
+        self.add_route(path, route, methods=["GET", "POST"])
+
+    def add_websocket_route(self, path: str, route: typing.Callable) -> None:
+        if not inspect.isclass(route):
+            route = websocket_session(route)
+
+        instance = Path(path, route, protocol="websocket")
+        self.routes.append(instance)
+
+    def route(self, path: str, methods: typing.Sequence[str] = None) -> typing.Callable:
+        def decorator(func: typing.Callable) -> typing.Callable:
+            self.add_route(path, func, methods=methods)
+            return func
+
+        return decorator
+
+    def websocket_route(self, path: str) -> typing.Callable:
+        def decorator(func: typing.Callable) -> typing.Callable:
+            self.add_websocket_route(path, func)
+            return func
+
+        return decorator
 
     def __call__(self, scope: Scope) -> ASGIInstance:
         assert scope["type"] in ("http", "websocket")
+        scope["executor"] = self.executor
 
         for route in self.routes:
             matched, child_scope = route.matches(scope)


Test patch:
diff --git a/tests/test_applications.py b/tests/test_applications.py
--- a/tests/test_applications.py
+++ b/tests/test_applications.py
@@ -167,6 +167,35 @@ async def homepage(request):
     assert app.debug
 
 
+def test_app_add_route():
+    app = Starlette()
+
+    async def homepage(request):
+        return PlainTextResponse("Hello, World!")
+
+    app.add_route("/", homepage)
+    client = TestClient(app)
+    response = client.get("/")
+    assert response.status_code == 200
+    assert response.text == "Hello, World!"
+
+
+def test_app_add_websocket_route():
+    app = Starlette()
+
+    async def websocket_endpoint(session):
+        await session.accept()
+        await session.send_text("Hello, world!")
+        await session.close()
+
+    app.add_websocket_route("/ws", websocket_endpoint)
+    client = TestClient(app)
+
+    with client.websocket_connect("/ws") as session:
+        text = session.receive_text()
+        assert text == "Hello, world!"
+
+
 def test_app_add_event_handler():
     startup_complete = False
     cleanup_complete = False
diff --git a/tests/test_routing.py b/tests/test_routing.py
--- a/tests/test_routing.py
+++ b/tests/test_routing.py
@@ -34,9 +34,22 @@ def staticfiles(scope):
 )
 
 
-def test_router():
-    client = TestClient(app)
+@app.route("/func")
+def func_homepage(request):
+    return Response("Hello, world!", media_type="text/plain")
+
+
+@app.websocket_route("/ws")
+async def websocket_endpoint(session):
+    await session.accept()
+    await session.send_text("Hello, world!")
+    await session.close()
+
+
+client = TestClient(app)
+
 
+def test_router():
     response = client.get("/")
     assert response.status_code == 200
     assert response.text == "Hello, world"
@@ -66,6 +79,18 @@ def test_router():
     assert response.text == "Method Not Allowed"
 
 
+def test_router_add_route():
+    response = client.get("/func")
+    assert response.status_code == 200
+    assert response.text == "Hello, world!"
+
+
+def test_router_add_websocket_route():
+    with client.websocket_connect("/ws") as session:
+        text = session.receive_text()
+        assert text == "Hello, world!"
+
+
 def http_endpoint(scope):
     return Response("Hello, world", media_type="text/plain")