Repository: encode/starlette

Issue:
Include routing information for middleware
Is there a way to obtain which route the request is taking in middleware? Right now, the only available thing is `request.url.path`, which doesn't give me a nice name for metrics.

I see in https://github.com/steinnes/timing-asgi/blob/master/timing_asgi/integrations/starlette.py that it basically just loops through all the routes to obtain the matching one. Is there a better way to do it?

Looping in that manner also runs into an issue where if there are multiple matching routes, like
`/resource/default/1`
`/resource/1/1`
it can't find the right one since they both match.

<!-- POLAR PLEDGE BADGE START -->
> [!IMPORTANT]
> - We're using [Polar.sh](https://polar.sh/encode) so you can upvote and help fund this issue.
> - We receive the funding once the issue is completed & confirmed by you.
> - Thank you in advance for helping prioritize & fund our backlog.

<a href="https://polar.sh/encode/starlette/issues/685">
<picture>
  <source media="(prefers-color-scheme: dark)" srcset="https://polar.sh/api/github/encode/starlette/issues/685/pledge.svg?darkmode=1">
  <img alt="Fund with Polar" src="https://polar.sh/api/github/encode/starlette/issues/685/pledge.svg">
</picture>
</a>
<!-- POLAR PLEDGE BADGE END -->



Discussion:
I got the same problem as @cypai with `AuthenticationMiddleware`. There is only `request.scope["path"]` and `request.scope["raw_path"]` available and I've to make quite some regex to handle the route properly.

Is it possible to build the Route() before *Middleware is called so one can get the route_name and other useful informations in the middleware? Maybe @cypai and I are both missing something?
So, the routing only occurs *after* the middleware handling.

I think options here are:

* We could potentially add some public API on the application so that it's easier to premptively resolve the request routing? Ideally we ensure that can be cached, so that the lookup only occurs once.
* We could add the ability to have some kinds of middleware run *after* the request routing. (Probably not super keen on that.)

Thanks for your answer @tomchristie. I don't know Starlette enough to know which solution would be best. I know that in some other frameworks, there is usually some Events mechanisms with some Listeners attached to it.

If I can be of any help, let me know, we use Starlette in production in my company, so it's critical for us.
@tomchristie  why the second option not something you're keen on ? (honest question, I have no opinion) 
but yes I've run into this issue because I was trying to put in a ContextVars the route name, so that my logger can get this information 

right now a workaround I've found (as I'm using fastapi and not startlette directly, so not sure how much it applies on startette) is subclassing like here https://fastapi.tiangolo.com/tutorial/custom-request-and-route/#custom-apiroute-class-in-a-router  and there I can access the routing information I need before/after request
@allan-simon Can you share the code of your workaround?
sure thing 

```python
"""The goal is to have the route name of the current request                                                                                                                                 
accessible anywhere by our loggers to help us contextualizing                                                                                                                                
when debugging though kibana or other centralized log visualizer                                                                                                                             
"""                                                                                                                                                                                          
from typing import Callable                                                                                                                                                                  
from typing import Optional                                                                                                                                                                  
from contextvars import ContextVar                                                                                                                                                           
                                                                                                                                                                                             
from fastapi import APIRouter, FastAPI                                                                                                                                                       
from fastapi.routing import APIRoute                                                                                                                                                         
from starlette.requests import Request                                                                                                                                                       
from starlette.responses import Response                                                                                                                                                     
                                                                                                                                                                                             
                                                                                                                                                                                             
_route_name_ctx_var: ContextVar[Optional[str]] = ContextVar("route_name", default=None)                                                                                                      
                                                                                                                                                                                             
                                                                                                                                                                                             
def get_route_name() -> Optional[str]:                                                                                                                                                       
    return _route_name_ctx_var.get()                                                                                                                                                         
                                                                                                                                                                                             
                                                                                                                                                                                             
class LoggedRoute(APIRoute):                                                                                                                                                                 
    def get_route_handler(self) -> Callable:                                                                                                                                                 
        original_route_handler = super().get_route_handler()                                                                                                                                 
        route_name = self.name                                                                                                                                                               
                                                                                                                                                                                             
        # we import here to avoid the circular dependencies                                                                                                                                  
        from app.logging import get_logger                                                                                                                                                   
                                                                                                                                                                                             
        async def custom_route_handler(request: Request) -> Response:                                                                                                                        
                                                                                                                                                                                             
            _route_name_ctx_var.set(route_name)                                                                                                                                              
                                                                                                                                                                                             
            app_logger = get_logger()                                                                                                                                                        
            app_logger.info({"step": "start"})                                                                                                                                               
                                                                                                                                                                                             
            response: Response = await original_route_handler(request)                                                                                                                       
                                                                                                                                                                                             
            app_logger.info({"step": "end", "status_code": response.status_code})                                                                                                            
                                                                                                                                                                                             
            return response                                                                                                                                                                  
                                                                                                                                                                                             
        return custom_route_handler              
```

and then I use it like this:

```
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.engine import Connection

from app.database import get_connection
from app.logged_route import LoggedRoute


router = APIRouter(route_class=LoggedRoute)

```
@allan-simon Thanks. What is `get_route_name` used for?
@shizidushu  here I will not be able to simply copy paste you code,  but here basically the usage:

I've configured all the loggers to have a JSON Formatter in order for my logs to be then integrated into a centralized log system (ELK stack)
in addition to this all the loggers have also a logfilter which add in the json , the field "route_name" (and other field , like user_id , request_id etc. ) so that if anywhere in the code I do "logger.info("hey") "  I will be able to know it was called in the context of a given route 

the goal is that this way it's easy to make statistics by api endpoint name 
@allan-simon Thanks for your explanation. So, in your case, it is used to expose the route context ("route_name") to logger. It is an inspiring idea!
Question for the group here... tagging onto this thread because I think it may be related:

Am I correct in thinking route discovery is currently the best way to handle a pipeline of middleware for a given route or group of routes?

A little backdrop... I am thinking about the equivalent of a policies.js file in nodejs, where you may build a pipeline of checks for a given request:

`admin_policies = [https_redirect, is_logged_in, is_admin]`
`user_policies = [https_redirect, is_logged_in]`

My understanding is that policies (which are I think identical in implementation to middleware, though serve a subtly different purpose) are not directly supported in starlette, which is fine. That said, it might be nice to be able to assign functions (at the router level) assign those functions/routes to those policies so anyone who hits that route goes through that same pipeline.

I've found this kind of structure quite nice, and while starlette is meant to be lightweight and may expect something else to plug into it (which is fine), I'm curious if anyone has approached something similar... and if so, how?

EDIT: Please let me know if you think this question is more appropriate as its own issue.
Just wanted to add my voice to this issue. We want to log response times per endpoint. As this is done at the end of the request, it is perfectly fine for us to not know the endpoint name until the request has been resolved.

The workaround posted by @allan-simon might work, but I would rather handle response times in a middleware than in the routes themselves.
As a FastAPI user, I feel that one of the advantages of their dependency injection system is being able to configure it on a per route, per router or per app basis. Maybe Starlette could support something similar by having middleware for routes & mounts? That way there is no need to have an option in the middleware that says `match/exclude these routes`. But this would still require having some sort of "middleware after routing".
> We could add the ability to have some kinds of middleware run _after_ the request routing. (Probably not super keen on that.)

@tomchristie could you elaborate on why you think this isn't a good option? I think the implementation is relatively straightforward.


Resolving patch:
diff --git a/starlette/routing.py b/starlette/routing.py
--- a/starlette/routing.py
+++ b/starlette/routing.py
@@ -14,6 +14,7 @@
 from starlette.convertors import CONVERTOR_TYPES, Convertor
 from starlette.datastructures import URL, Headers, URLPath
 from starlette.exceptions import HTTPException
+from starlette.middleware import Middleware
 from starlette.requests import Request
 from starlette.responses import PlainTextResponse, RedirectResponse
 from starlette.types import ASGIApp, Receive, Scope, Send
@@ -348,6 +349,8 @@ def __init__(
         app: typing.Optional[ASGIApp] = None,
         routes: typing.Optional[typing.Sequence[BaseRoute]] = None,
         name: typing.Optional[str] = None,
+        *,
+        middleware: typing.Optional[typing.Sequence[Middleware]] = None,
     ) -> None:
         assert path == "" or path.startswith("/"), "Routed paths must start with '/'"
         assert (
@@ -355,9 +358,13 @@ def __init__(
         ), "Either 'app=...', or 'routes=' must be specified"
         self.path = path.rstrip("/")
         if app is not None:
-            self.app: ASGIApp = app
+            self._base_app: ASGIApp = app
         else:
-            self.app = Router(routes=routes)
+            self._base_app = Router(routes=routes)
+        self.app = self._base_app
+        if middleware is not None:
+            for cls, options in reversed(middleware):
+                self.app = cls(app=self.app, **options)
         self.name = name
         self.path_regex, self.path_format, self.param_convertors = compile_path(
             self.path + "/{path:path}"
@@ -365,7 +372,7 @@ def __init__(
 
     @property
     def routes(self) -> typing.List[BaseRoute]:
-        return getattr(self.app, "routes", [])
+        return getattr(self._base_app, "routes", [])
 
     def matches(self, scope: Scope) -> typing.Tuple[Match, Scope]:
         if scope["type"] in ("http", "websocket"):


Test patch:
diff --git a/tests/test_routing.py b/tests/test_routing.py
--- a/tests/test_routing.py
+++ b/tests/test_routing.py
@@ -5,8 +5,13 @@
 import pytest
 
 from starlette.applications import Starlette
+from starlette.exceptions import HTTPException
+from starlette.middleware import Middleware
+from starlette.requests import Request
 from starlette.responses import JSONResponse, PlainTextResponse, Response
 from starlette.routing import Host, Mount, NoMatchFound, Route, Router, WebSocketRoute
+from starlette.testclient import TestClient
+from starlette.types import ASGIApp, Message, Receive, Scope, Send
 from starlette.websockets import WebSocket, WebSocketDisconnect
 
 
@@ -768,6 +773,115 @@ def test_route_name(endpoint: typing.Callable, expected_name: str):
     assert Route(path="/", endpoint=endpoint).name == expected_name
 
 
+class AddHeadersMiddleware:
+    def __init__(self, app: ASGIApp) -> None:
+        self.app = app
+
+    async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
+        scope["add_headers_middleware"] = True
+
+        async def modified_send(msg: Message) -> None:
+            if msg["type"] == "http.response.start":
+                msg["headers"].append((b"X-Test", b"Set by middleware"))
+            await send(msg)
+
+        await self.app(scope, receive, modified_send)
+
+
+def assert_middleware_header_route(request: Request) -> Response:
+    assert request.scope["add_headers_middleware"] is True
+    return Response()
+
+
+mounted_routes_with_middleware = Starlette(
+    routes=[
+        Mount(
+            "/http",
+            routes=[
+                Route(
+                    "/",
+                    endpoint=assert_middleware_header_route,
+                    methods=["GET"],
+                    name="route",
+                ),
+            ],
+            middleware=[Middleware(AddHeadersMiddleware)],
+        ),
+        Route("/home", homepage),
+    ]
+)
+
+
+mounted_app_with_middleware = Starlette(
+    routes=[
+        Mount(
+            "/http",
+            app=Route(
+                "/",
+                endpoint=assert_middleware_header_route,
+                methods=["GET"],
+                name="route",
+            ),
+            middleware=[Middleware(AddHeadersMiddleware)],
+        ),
+        Route("/home", homepage),
+    ]
+)
+
+
+@pytest.mark.parametrize(
+    "app",
+    [
+        mounted_routes_with_middleware,
+        mounted_app_with_middleware,
+    ],
+)
+def test_mount_middleware(
+    test_client_factory: typing.Callable[..., TestClient],
+    app: Starlette,
+) -> None:
+    test_client = test_client_factory(app)
+
+    response = test_client.get("/home")
+    assert response.status_code == 200
+    assert "X-Test" not in response.headers
+
+    response = test_client.get("/http")
+    assert response.status_code == 200
+    assert response.headers["X-Test"] == "Set by middleware"
+
+
+def test_mount_routes_with_middleware_url_path_for() -> None:
+    """Checks that url_path_for still works with mounted routes with Middleware"""
+    assert mounted_routes_with_middleware.url_path_for("route") == "/http/"
+
+
+def test_mount_asgi_app_with_middleware_url_path_for() -> None:
+    """Mounted ASGI apps do not work with url path for,
+    middleware does not change this
+    """
+    with pytest.raises(NoMatchFound):
+        mounted_app_with_middleware.url_path_for("route")
+
+
+def test_add_route_to_app_after_mount(
+    test_client_factory: typing.Callable[..., TestClient],
+) -> None:
+    """Checks that Mount will pick up routes
+    added to the underlying app after it is mounted
+    """
+    inner_app = Router()
+    app = Mount("/http", app=inner_app)
+    inner_app.add_route(
+        "/inner",
+        endpoint=homepage,
+        methods=["GET"],
+    )
+    client = test_client_factory(app)
+    response = client.get("/http/inner")
+    assert response.status_code == 200
+
+
 def test_exception_on_mounted_apps(test_client_factory):
     def exc(request):
         raise Exception("Exc")
@@ -779,3 +893,62 @@ def exc(request):
     with pytest.raises(Exception) as ctx:
         client.get("/sub/")
     assert str(ctx.value) == "Exc"
+
+
+def test_mounted_middleware_does_not_catch_exception(
+    test_client_factory: typing.Callable[..., TestClient],
+) -> None:
+    # https://github.com/encode/starlette/pull/1649#discussion_r960236107
+    def exc(request: Request) -> Response:
+        raise HTTPException(status_code=403, detail="auth")
+
+    class NamedMiddleware:
+        def __init__(self, app: ASGIApp, name: str) -> None:
+            self.app = app
+            self.name = name
+
+        async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
+            async def modified_send(msg: Message) -> None:
+                if msg["type"] == "http.response.start":
+                    msg["headers"].append((f"X-{self.name}".encode(), b"true"))
+                await send(msg)
+
+            await self.app(scope, receive, modified_send)
+
+    app = Starlette(
+        routes=[
+            Mount(
+                "/mount",
+                routes=[
+                    Route("/err", exc),
+                    Route("/home", homepage),
+                ],
+                middleware=[Middleware(NamedMiddleware, name="Mounted")],
+            ),
+            Route("/err", exc),
+            Route("/home", homepage),
+        ],
+        middleware=[Middleware(NamedMiddleware, name="Outer")],
+    )
+
+    client = test_client_factory(app)
+
+    resp = client.get("/home")
+    assert resp.status_code == 200, resp.content
+    assert "X-Outer" in resp.headers
+
+    resp = client.get("/err")
+    assert resp.status_code == 403, resp.content
+    assert "X-Outer" in resp.headers
+
+    resp = client.get("/mount/home")
+    assert resp.status_code == 200, resp.content
+    assert "X-Mounted" in resp.headers
+
+    # this is the "surprising" behavior bit
+    # the middleware on the mount never runs because there
+    # is nothing to catch the HTTPException
+    # since Mount middlweare is not wrapped by ExceptionMiddleware
+    resp = client.get("/mount/err")
+    assert resp.status_code == 403, resp.content
+    assert "X-Mounted" not in resp.headers