Skip to content

Commit 0800a58

Browse files
authored
feat: Middleware constraints (#4167)
1 parent a8ef67c commit 0800a58

File tree

14 files changed

+1098
-22
lines changed

14 files changed

+1098
-22
lines changed
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
from litestar.middleware.authentication import AbstractAuthenticationMiddleware
2+
from litestar.middleware.base import ASGIMiddleware
3+
from litestar.middleware.constraints import MiddlewareConstraints
4+
5+
6+
class CachingMiddleware(ASGIMiddleware):
7+
constraints = MiddlewareConstraints(after=(AbstractAuthenticationMiddleware,))
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
from litestar.middleware.base import ASGIMiddleware
2+
from litestar.middleware.constraints import MiddlewareConstraints
3+
4+
5+
class SomeMiddleware(ASGIMiddleware):
6+
constraints = MiddlewareConstraints().apply_after(
7+
"some_package.some_module.SomeMiddleware",
8+
ignore_import_error=True,
9+
)
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
constraints
2+
===========
3+
4+
.. automodule:: litestar.middleware.constraints
5+
:members:

docs/reference/middleware/index.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,4 @@ middleware
1515
logging
1616
rate_limit
1717
session/index
18+
constraints

docs/release-notes/whats-new-3.rst

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -332,3 +332,15 @@ parameter.
332332
.. attention::
333333
This has been intentionally made a breaking change because the new parameter has
334334
slightly different behaviour and defaults to ``False`` instead of ``True``.
335+
336+
337+
Middleware configuration constraints
338+
-------------------------------------
339+
340+
:class:`~litestar.middleware.ASGIMiddleware`\ s can now express constraints for how
341+
they are applied in the middleware stack, which are then validated on application
342+
startup.
343+
344+
.. seealso::
345+
346+
:ref:`usage/middleware/creating-middleware:configuration-constraints

docs/usage/middleware/creating-middleware.rst

Lines changed: 85 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
21
Creating Middleware
32
===================
43

@@ -47,6 +46,91 @@ outgoing responses:
4746
:language: python
4847

4948

49+
Configuration constraints
50+
++++++++++++++++++++++++++
51+
52+
While it's good practice to keep middlewares decoupled from another, there are times
53+
where implicit coupling is unavoidable due to the nature of the functionality provided
54+
by the middlewares.
55+
56+
For example a caching middleware and an authentication middleware
57+
can produce very different results depending on the order they are applied in; Assuming
58+
a naive caching middleware that does not take authentication state into account, if it's
59+
applied *before* the authentication middleware, it might cache an authenticated response
60+
and serve it to the next, unauthenticated request.
61+
62+
Especially when applications grow larger and more complex, it can become difficult to
63+
keep track of all these implicit couplings and dependencies, or downright impossible if
64+
the middleware is implemented in a separate package and has no knowledge about how it is
65+
being applied.
66+
67+
To help with this, :class:`~litestar.middleware.ASGIMiddleware` allows to specify a set
68+
of :class:`~litestar.middleware.constraints.MiddlewareConstraints` - Once configured,
69+
these will be validated on application startup.
70+
71+
Using constraints, the example given above might be solved like this:
72+
73+
.. literalinclude:: /examples/middleware/constraints.py
74+
:language: python
75+
76+
Here, we specify that every instance of ``CachingMiddleware`` must come after any
77+
instance of
78+
:class:`~litestar.middleware.authentication.AbstractAuthenticationMiddleware`.
79+
80+
81+
.. tip::
82+
83+
When referencing classes, the constraints always apply to all instances and
84+
subclasses of the type
85+
86+
87+
Forward references
88+
~~~~~~~~~~~~~~~~~~
89+
90+
Constraints that reference other middleware can use strings as forward references, to
91+
handle situations like circular imports or middlewares from packages that may not be
92+
available:
93+
94+
.. literalinclude:: /examples/middleware/constraints_string_ref.py
95+
:language: python
96+
97+
This forward reference will try to import ``SomeMiddleware`` from
98+
``some_package.some_module``. With ``ignore_import_error=True``, if the import is not
99+
successful, the constraint will be ignored.
100+
101+
102+
Middleware order
103+
~~~~~~~~~~~~~~~~
104+
105+
For order constraints (``before``, ``after``, ``first``, ``last``), it is important to
106+
note that the order is defined in terms of proximity to the location. In practice, this
107+
means that a middleware that has set ``first=True`` must be the *first* middleware on
108+
the *first* layer (i.e. the application), and a middleware setting ``last=True`` must
109+
be the *last* middleware on the *last* layer (i.e. the route handler).
110+
111+
.. code-block:: python
112+
113+
@get("/", middleware=[FifthMiddleware, SixthMiddleware])
114+
async def handler() -> None:
115+
pass
116+
117+
router = Router(
118+
"/",
119+
[handler],
120+
middleware=[
121+
ThirdMiddleware(),
122+
FourthMiddleware()
123+
]
124+
)
125+
126+
app = Litestar(
127+
middleware=[
128+
FirstMiddleware(),
129+
SecondMiddleware()
130+
]
131+
)
132+
133+
50134
51135
Migrating from ``MiddlewareProtocol`` / ``AbstractMiddleware``
52136
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

docs/usage/middleware/using-middleware.rst

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -61,15 +61,39 @@ because we declared it on the application level.
6161
Middleware Call Order
6262
---------------------
6363

64-
Since it's also possible to define multiple middlewares on every layer, the call order for
65-
middlewares will be **top to bottom** and **left to right**. This means for each layer, the
66-
middlewares will be called in the order they have been passed, while the layers will be
67-
traversed in the usual order:
64+
due to the way we're traversing over the app layers, the middleware stack is
65+
constructed in 'application > handler' order, which is the order we want the
66+
middleware to be called in.
67+
68+
using this order however, since each middleware wraps the next callable, the
69+
*first* middleware in the stack would up being the *innermost* wrapper, i.e.
70+
the last one to receive the request and the first one to see the response.
71+
72+
to achieve the intended call order, we perform the wrapping in reverse
73+
('handler -> application').
74+
6875

6976
.. mermaid::
7077

71-
flowchart LR
72-
Application --> Router --> Controller --> Handler
78+
graph TD
79+
request --> M1
80+
M1 --> M2
81+
M2 --> H
82+
H --> M2R
83+
M2R --> M1R
84+
M1R --> response
85+
86+
subgraph M1 [middleware_1]
87+
M2
88+
subgraph M2 [middleware_2]
89+
H[handler]
90+
end
91+
end
92+
93+
style M1 stroke:#333,stroke-width:2px
94+
style M2 stroke:#555,stroke-width:1.5px
95+
style H stroke:#777,stroke-width:1px
96+
7397

7498

7599
.. literalinclude:: /examples/middleware/call_order.py

litestar/_asgi/routing_trie/mapping.py

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,34 @@ def build_route_middleware_stack(
214214
if app.allowed_hosts:
215215
asgi_handler = AllowedHostsMiddleware(app=asgi_handler, config=app.allowed_hosts)
216216

217-
for middleware in handler_middleware:
217+
# due to the way we're traversing over the app layers, the middleware stack is
218+
# constructed in 'application > handler' order, which is the order we want the
219+
# middleware to be called in.
220+
#
221+
# using this order however, since each middleware wraps the next callable, the
222+
# *first* middleware in the stack would up being the *innermost* wrapper, i.e.
223+
# the last one to receive the request and the first one to see the response.
224+
#
225+
# to achieve the intended call order, we perform the wrapping in reverse
226+
# ('handler -> application').
227+
#
228+
# example:
229+
# given: an 'application' with 'middleware_1', a 'handler' with 'middleware_2'
230+
# resolved stack: [middleware_1, middleware_2]
231+
# desired call chain: middleware_1 -> middleware_2 -> handler
232+
# wrapping structure:
233+
#
234+
# request -->
235+
# +--------------------+
236+
# | middleware_1 | <-- outermost
237+
# | +--------------+ |
238+
# | | middleware_2 | |
239+
# | | +--------+ | |
240+
# | | | handler| | |
241+
# | | +--------+ | |
242+
# | +--------------+ |
243+
# +--------------------+
244+
# --> response
245+
for middleware in reversed(handler_middleware):
218246
asgi_handler = middleware(asgi_handler)
219247
return asgi_handler

litestar/handlers/base.py

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from litestar.di import Provide
99
from litestar.dto import DTOData
1010
from litestar.exceptions import ImproperlyConfiguredException, LitestarException
11+
from litestar.middleware.constraints import check_middleware_constraints
1112
from litestar.serialization import default_deserializer, default_serializer
1213
from litestar.types import (
1314
Dependencies,
@@ -214,15 +215,6 @@ def _get_merge_opts(self, others: tuple[Router, ...]) -> dict[str, Any]:
214215
merge_opts["dto"] = value_or_default(self._dto, merge_opts.get("dto", Empty))
215216
merge_opts["return_dto"] = value_or_default(self._return_dto, merge_opts.get("return_dto", Empty))
216217

217-
# due to the way we're traversing over the app layers, the middleware stack is
218-
# constructed in the wrong order (handler > application). reversing the order
219-
# here is easier than handling it correctly at every intermediary step.
220-
#
221-
# we only call this if 'others' is non-empty, to ensure we don't change anything
222-
# if no layers have been merged (happens in '._with_changes' for example)
223-
if others:
224-
merge_opts["middleware"] = tuple(reversed(merge_opts["middleware"]))
225-
226218
return merge_opts
227219

228220
def _with_changes(self, **kwargs: Any) -> Self:
@@ -520,6 +512,8 @@ def on_registration(self, route: BaseRoute, app: Litestar) -> None:
520512
self._validate_handler_function()
521513
self._finalize_dependencies(app=app)
522514

515+
check_middleware_constraints(self.middleware)
516+
523517
def _validate_handler_function(self) -> None:
524518
"""Validate the route handler function once set by inspecting its return annotations."""
525519
if self.parsed_data_field is not None and self.parsed_data_field.is_subclass_of(DTOData) and not self.data_dto:

litestar/middleware/base.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@
1818
"MiddlewareProtocol",
1919
)
2020

21-
2221
if TYPE_CHECKING:
22+
from litestar.middleware.constraints import MiddlewareConstraints
2323
from litestar.types import Scopes
2424
from litestar.types.asgi_types import ASGIApp, Receive, Scope, Send
2525

@@ -170,9 +170,10 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
170170

171171

172172
class ASGIMiddleware(abc.ABC):
173-
"""An abstract base class to easily construct ASGI middlewares, providing functionality
174-
to dynamically skip the middleware based on ASGI ``scope["type"]``, handler ``opt``
175-
keys or path patterns and a simple way to pass configuration to middlewares.
173+
"""An abstract base class to easily construct ASGI middlewares, providing
174+
functionality to dynamically skip the middleware based on ASGI ``scope["type"]``,
175+
handler ``opt`` keys or path patterns and a simple way to pass configuration to
176+
middlewares.
176177
177178
This base class does not implement an ``__init__`` method, so subclasses are free
178179
to use it to customize the middleware's configuration.
@@ -216,6 +217,7 @@ async def handle(
216217
)
217218
exclude_path_pattern: str | tuple[str, ...] | None = None
218219
exclude_opt_key: str | None = None
220+
constraints: MiddlewareConstraints | None = None
219221

220222
def __call__(self, app: ASGIApp) -> ASGIApp:
221223
"""Create the actual middleware callable"""

0 commit comments

Comments
 (0)