diff --git a/slowapi/middleware.py b/slowapi/middleware.py index 76cdeec..a499edc 100644 --- a/slowapi/middleware.py +++ b/slowapi/middleware.py @@ -20,6 +20,19 @@ def _find_route_handler( ) -> Optional[Callable]: handler = None for route in routes: + effective_route_contexts = getattr(route, "effective_route_contexts", None) + if effective_route_contexts is not None: + # FastAPI >= 0.137 includes routers lazily: `app.routes` holds a wrapper + # instead of the included routes, and the wrapper has no `endpoint`, so the + # loop below would not find any handler for those paths. The routes are + # reachable one level down, and each context exposes both `matches()` and + # `endpoint`, so it can be treated as a route by this same lookup. + # `getattr(..., None)` keeps this a no-op on plain Starlette apps and on + # FastAPI versions that include routers eagerly. + inner = _find_route_handler(list(effective_route_contexts()), scope) + if inner is not None: + handler = inner + continue match, _ = route.matches(scope) if match == Match.FULL and hasattr(route, "endpoint"): handler = route.endpoint # type: ignore diff --git a/tests/test_fastapi_extension.py b/tests/test_fastapi_extension.py index 42e6322..45b6700 100644 --- a/tests/test_fastapi_extension.py +++ b/tests/test_fastapi_extension.py @@ -1,9 +1,12 @@ import hiro # type: ignore import pytest # type: ignore +from fastapi import APIRouter from starlette.requests import Request from starlette.responses import PlainTextResponse, Response +from starlette.routing import Match from starlette.testclient import TestClient +from slowapi.middleware import _find_route_handler from slowapi.util import get_ipaddr from tests import TestSlowapi @@ -369,3 +372,48 @@ async def t1_func(my_param: str, request: Request): ) == 2 ) + + +def test_find_route_handler_resolves_lazily_included_routes(): + """FastAPI >= 0.137 includes routers lazily, exposing the included routes through + `effective_route_contexts()` instead of putting them in `app.routes`. The lookup has + to follow that indirection, otherwise it returns None and every request to an + included route is treated as exempt.""" + + def included_endpoint(request: Request) -> Response: + return PlainTextResponse("test") + + class _Context: + endpoint = staticmethod(included_endpoint) + + def matches(self, scope): + return Match.FULL, {} + + class _LazilyIncludedRouter: + def effective_route_contexts(self): + return [_Context()] + + def matches(self, scope): # pragma: no cover - never reached for this route type + return Match.NONE, {} + + scope = {"type": "http", "method": "GET", "path": "/included"} + assert _find_route_handler([_LazilyIncludedRouter()], scope) is included_endpoint + + +class TestIncludedRouter(TestSlowapi): + def test_default_limits_apply_to_routes_from_include_router(self, build_fastapi_app): + """Default limits applied by the middleware must reach routes added through + `include_router`, not only routes declared directly on the app.""" + app, limiter = build_fastapi_app(key_func=get_ipaddr, default_limits=["3/minute"]) + + router = APIRouter() + + @router.get("/included") + async def included(request: Request): + return PlainTextResponse("test") + + app.include_router(router) + + client = TestClient(app) + codes = [client.get("/included").status_code for _ in range(0, 5)] + assert codes == [200, 200, 200, 429, 429]