Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions slowapi/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
48 changes: 48 additions & 0 deletions tests/test_fastapi_extension.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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]