Skip to content

Commit 94073d3

Browse files
authored
feat(middleware): Add should_bypass_for_scope to ASGIMiddleware to allow excluding middlewares dynamically (#4441)
1 parent afc255f commit 94073d3

File tree

3 files changed

+77
-6
lines changed

3 files changed

+77
-6
lines changed

docs/release-notes/changelog.rst

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -277,3 +277,12 @@
277277
id: ReadOnly[int]
278278
279279
``typing_extensions.ReadOnly`` should be used for python versions <3.13.
280+
281+
282+
.. change:: Add ``should_bypass_for_scope`` to ``ASGIMiddleware`` to allow excluding middlewares dynamically
283+
:type: feature
284+
:pr: 4441
285+
286+
Add a new attribute :attr:`~litestar.middleware.ASGIMiddleware.should_bypass_for_scope`;
287+
A callable which takes in a :class:`~litestar.types.Scope` and returns a boolean
288+
to indicate whether to bypass the middleware for the current request.

litestar/middleware/base.py

Lines changed: 45 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,10 @@ async def handle(
226226
``/user/{user_id:int}/``), NOT against the actual **request path** (e.g.,
227227
``/user/1234/``). This is a critical distinction for dynamic routes.
228228
229+
If you need to exclude based on paths dynamically, use
230+
:attr:`~litestar.middleware.ASGIMiddleware.should_bypass_for_scope`
231+
instead, matching on ``scope["path"]``.
232+
229233
**Example 1: Static path**
230234
231235
Handler path::
@@ -250,19 +254,44 @@ async def handle(
250254
/user/5678/profile
251255
/user/9999/profile
252256
253-
To exclude this handler, the pattern must match the **handler**, not the actual
254-
request path:
257+
To exclude this handler, the pattern must match the **handler**, not the actual request path::
255258
256259
exclude_path_pattern = "/user/{user_id:int}/profile"
257260
exclude_path_pattern = "/user/\{.+?\}/"
258261
"""
259262
exclude_opt_key: str | None = None
263+
"""
264+
Exclude this middleware for handlers with an opt-key of this name that is truthy
265+
"""
266+
should_bypass_for_scope: Callable[[Scope], bool] | None = None
267+
r"""
268+
A callable that takes in the :class:`~litestar.types.Scope` of the current
269+
connection and returns a boolean, indicating if the middleware should be skipped for
270+
the current request.
271+
272+
This can for example be used to exclude a middleware based on a dynamic path::
273+
274+
should_bypass_for_scope = lambda scope: scope["path"].endswith(".jpg")
275+
276+
Applied to a route with a dynamic path like ``/static/{file_name:str}``, it would
277+
be skipped *only* if ``file_name`` has a ``.jpg`` extension.
278+
279+
.. note::
280+
281+
If it is not required to dynamically match the path of a request,
282+
:attr:`~litestar.middleware.ASGIMiddleware.exclude_path_pattern` should be
283+
used instead. Since its exclusion is done statically at startup time, it has no
284+
performance cost at runtime.
285+
286+
.. versionadded:: 3.0
287+
"""
260288
constraints: MiddlewareConstraints | None = None
261289

262290
def should_bypass_for_handler(self, handler: RouteHandlerType) -> bool:
263291
"""Return ``True`` if this middleware should be bypassed for ``handler``, according
264-
to ``scopes``, ``exclude_path_pattern`` or ``exclude_opt_key``, otherwise
265-
``False``.
292+
to :attr:`~litestar.middleware.ASGIMiddleware.scopes`,
293+
:attr:`~litestar.middleware.ASGIMiddleware.exclude_path_pattern` or
294+
:attr:`~litestar.middleware.ASGIMiddleware.exclude_opt_key`, otherwise ``False``.
266295
"""
267296
from litestar.handlers import ASGIRouteHandler, HTTPRouteHandler, WebsocketRouteHandler
268297

@@ -286,8 +315,18 @@ def __call__(self, app: ASGIApp) -> ASGIApp:
286315
"""Create the actual middleware callable"""
287316
handle = self.handle
288317

289-
async def middleware(scope: Scope, receive: Receive, send: Send) -> None:
290-
await handle(scope=scope, receive=receive, send=send, next_app=app)
318+
should_bypass_for_scope = self.should_bypass_for_scope
319+
if should_bypass_for_scope is None:
320+
321+
async def middleware(scope: Scope, receive: Receive, send: Send) -> None:
322+
await handle(scope=scope, receive=receive, send=send, next_app=app)
323+
else:
324+
325+
async def middleware(scope: Scope, receive: Receive, send: Send) -> None:
326+
if should_bypass_for_scope(scope):
327+
await app(scope, receive, send)
328+
else:
329+
await handle(scope=scope, receive=receive, send=send, next_app=app)
291330

292331
return middleware
293332

tests/unit/test_middleware/test_base_middleware.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -345,6 +345,29 @@ def handler(bar: int) -> None:
345345
mock.assert_not_called()
346346

347347

348+
def test_asgi_middleware_should_exclude_scope() -> None:
349+
mock = MagicMock()
350+
351+
class SubclassMiddleware(ASGIMiddleware):
352+
@staticmethod
353+
def should_bypass_for_scope(scope: "Scope") -> bool:
354+
return scope["path"].endswith(".jpg")
355+
356+
async def handle(self, scope: "Scope", receive: "Receive", send: "Send", next_app: "ASGIApp") -> None:
357+
mock(scope["path"])
358+
await next_app(scope, receive, send)
359+
360+
@get("/{file_name:str}")
361+
def handler(file_name: str) -> str:
362+
return file_name
363+
364+
with create_test_client([handler], middleware=[SubclassMiddleware()]) as client:
365+
assert client.get("/test.txt").status_code == 200
366+
assert client.get("/test.jpg").status_code == 200
367+
368+
mock.assert_called_once_with("/test.txt")
369+
370+
348371
@pytest.mark.parametrize("excludes", ["/", ("/", "/foo"), "/*", "/.*"])
349372
def test_asgi_middleware_exclude_by_pattern_warns_if_exclude_all(excludes: Union[str, tuple[str, ...]]) -> None:
350373
class SubclassMiddleware(ASGIMiddleware):

0 commit comments

Comments
 (0)