Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add route-level middleware #1286

Closed
wants to merge 49 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
c2809bd
Add route-level middleware
adriangb Sep 13, 2021
2fb13a6
Remove websocker stuff
adriangb Sep 13, 2021
e57a42f
Remove spaces
adriangb Sep 13, 2021
6e4c6dc
remove newline
adriangb Sep 13, 2021
ad31857
revert more changes
adriangb Sep 13, 2021
fdbdfc0
Merge branch 'master' into route-middleware
adriangb Sep 18, 2021
4549f2c
linting
adriangb Sep 18, 2021
b56203c
Merge branch 'master' into route-middleware
adriangb Oct 2, 2021
5c4bb5e
Merge branch 'master' into route-middleware
adriangb Oct 4, 2021
5d0719d
Merge branch 'master' into route-middleware
adriangb Oct 5, 2021
fed281b
Merge branch 'master' into route-middleware
adriangb Oct 15, 2021
7a4e991
Merge branch 'master' into route-middleware
adriangb Oct 20, 2021
90e85f4
Merge branch 'master' into route-middleware
adriangb Nov 4, 2021
013bc9e
Merge branch 'master' into route-middleware
adriangb Nov 10, 2021
5ec5871
add Mount middleware and docs
adriangb Nov 11, 2021
3c64e17
add warning about modifying the path
adriangb Nov 11, 2021
586ed21
combine tests
adriangb Nov 11, 2021
24988bf
Update docs/middleware.md
adriangb Nov 11, 2021
73f920c
linting
adriangb Nov 15, 2021
e8dcb7f
Merge branch 'master' into route-middleware
adriangb Nov 22, 2021
14f85bd
Add note on error handling
adriangb Nov 22, 2021
eb7d41d
capture routes before wrapping
adriangb Nov 23, 2021
b78aa8a
Merge branch 'route-middleware' of https://github.com/adriangb/starle…
adriangb Nov 23, 2021
55b44f3
Merge branch 'master' into route-middleware
adriangb Nov 25, 2021
e25d88a
Merge branch 'master' into route-middleware
adriangb Dec 7, 2021
93ef0e3
chore: run linting
adriangb Dec 7, 2021
425c079
Merge branch 'master' into route-middleware
adriangb Dec 15, 2021
9783b8f
Merge branch 'master' into route-middleware
adriangb Dec 16, 2021
1f6b230
Merge branch 'master' into route-middleware
adriangb Dec 17, 2021
b225269
fix botched merge
adriangb Dec 17, 2021
62c3b61
add test for preservation of behavior of modifying an app after mounting
adriangb Dec 18, 2021
c25ac02
lint
adriangb Dec 22, 2021
3562102
Merge branch 'master' into route-middleware
adriangb Dec 24, 2021
284e776
Merge branch 'master' into route-middleware
adriangb Jan 2, 2022
492797c
Merge branch 'master' into route-middleware
adriangb Jan 5, 2022
bc6db94
Merge branch 'master' into route-middleware
adriangb Jan 6, 2022
40ddec2
Merge branch 'master' into route-middleware
adriangb Jan 9, 2022
e948795
Merge branch 'master' into route-middleware
adriangb Jan 10, 2022
6ec05f2
remove unused variable
adriangb Jan 10, 2022
b58f0c3
add comment on why we dynamically fetch routes
adriangb Jan 10, 2022
e9ff903
grab routes in __init__ and remove _user_app
adriangb Jan 10, 2022
6589620
Merge branch 'master' into route-middleware
adriangb Jan 14, 2022
47fe966
Merge branch 'master' into route-middleware
adriangb Jan 19, 2022
3b01035
Update starlette/routing.py
adriangb Jan 29, 2022
15b136b
Merge branch 'master' into route-middleware
adriangb Jan 29, 2022
530f8c2
Merge branch 'master' into route-middleware
adriangb Jan 31, 2022
77f50a7
Merge branch 'master' into route-middleware
adriangb Feb 1, 2022
20dbbe5
Merge branch 'master' into route-middleware
adriangb Feb 1, 2022
6120cbc
Merge branch 'master' into route-middleware
adriangb Mar 10, 2022
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
29 changes: 29 additions & 0 deletions docs/middleware.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,37 @@ application would look like this:
* `HTTPSRedirectMiddleware`
* `ExceptionMiddleware`
* Routing
* Route middleware
* Endpoint

Middleware can also be added at the route level, in which case it will be executed after routing occurs:

```python
from starlette.applications import Starlette
from starlette.middleware.trustedhost import TrustedHostMiddleware

example_route_middleware = [
Middleware(TrustedHostMiddleware, allowed_hosts=['example.com', '*.example.com']),
]

routes = [
Route(
"/example",
endpoint=...,
middleware=example_route_middleware,
)
]

middleware = [
Middleware(HTTPSRedirectMiddleware)
]

app = Starlette(routes=routes, middleware=middleware)
```

Note that since this is run after routing, modifying the path in the middleware will have no effect.
There is also no built-in error handling for route middleware, so your middleware will need to handle exceptions and resource cleanup itself.

The following middleware implementations are available in the Starlette package:

## CORSMiddleware
Expand Down
19 changes: 17 additions & 2 deletions starlette/routing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -195,6 +196,7 @@ def __init__(
methods: typing.List[str] = None,
name: str = None,
include_in_schema: bool = True,
middleware: typing.Optional[typing.Sequence[Middleware]] = None,
) -> None:
assert path.startswith("/"), "Routed paths must start with '/'"
self.path = path
Expand All @@ -214,6 +216,10 @@ def __init__(
# Endpoint is a class. Treat it as ASGI.
self.app = endpoint

if middleware is not None:
for cls, options in reversed(middleware):
self.app = cls(app=self.app, **options)

if methods is None:
self.methods = None
else:
Expand Down Expand Up @@ -333,12 +339,16 @@ def __eq__(self, other: typing.Any) -> bool:


class Mount(BaseRoute):
_routes: typing.List[BaseRoute]

def __init__(
self,
path: str,
app: ASGIApp = None,
routes: typing.Sequence[BaseRoute] = None,
name: str = None,
*,
middleware: typing.Optional[typing.Sequence[Middleware]] = None,
) -> None:
assert path == "" or path.startswith("/"), "Routed paths must start with '/'"
assert (
Expand All @@ -349,14 +359,19 @@ def __init__(
self.app: ASGIApp = app
else:
self.app = Router(routes=routes)
self._routes = getattr(self.app, "routes", [])
self.name = name
self.path_regex, self.path_format, self.param_convertors = compile_path(
self.path + "/{path:path}"
)

if middleware is not None:
for cls, options in reversed(middleware):
self.app = cls(app=self.app, **options)

@property
def routes(self) -> typing.List[BaseRoute]:
return getattr(self.app, "routes", [])
return self._routes

def matches(self, scope: Scope) -> typing.Tuple[Match, Scope]:
if scope["type"] in ("http", "websocket"):
Expand Down Expand Up @@ -404,7 +419,7 @@ def url_path_for(self, name: str, **path_params: typing.Any) -> URLPath:
)
if path_kwarg is not None:
remaining_params["path"] = path_kwarg
for route in self.routes or []:
for route in self.routes:
try:
url = route.url_path_for(remaining_name, **remaining_params)
return URLPath(
Expand Down
144 changes: 143 additions & 1 deletion tests/test_routing.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,24 @@
import functools
import typing
import uuid

import pytest

from starlette.applications import Starlette
from starlette.middleware import Middleware
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
from starlette.requests import Request
from starlette.responses import JSONResponse, PlainTextResponse, Response
from starlette.routing import Host, Mount, NoMatchFound, Route, Router, WebSocketRoute
from starlette.routing import (
BaseRoute,
Host,
Mount,
NoMatchFound,
Route,
Router,
WebSocketRoute,
)
from starlette.testclient import TestClient
from starlette.websockets import WebSocket, WebSocketDisconnect


Expand Down Expand Up @@ -710,3 +723,132 @@ def test_duplicated_param_names():
match="Duplicated param names id, name at path /{id}/{name}/{id}/{name}",
):
Route("/{id}/{name}/{id}/{name}", user)


def assert_middleware_header_route(request: Request):
assert getattr(request.state, "middleware_touched") == "Set by middleware"
return Response()


class AddHeadersMiddleware(BaseHTTPMiddleware):
async def dispatch(
self, request: Request, call_next: RequestResponseEndpoint
) -> Response:
setattr(request.state, "middleware_touched", "Set by middleware")
response: Response = await call_next(request)
response.headers["X-Test"] = "Set by middleware"
return response


route_with_middleware = (
Route(
"/http",
endpoint=assert_middleware_header_route,
methods=["GET"],
middleware=[Middleware(AddHeadersMiddleware)],
),
)


mounted_routes_with_middleware = Mount(
"/http",
routes=[
Route(
"/",
endpoint=assert_middleware_header_route,
methods=["GET"],
name="route",
),
],
middleware=[Middleware(AddHeadersMiddleware)],
)


mounted_app_with_middleware = Mount(
"/http",
app=Route(
"/",
endpoint=assert_middleware_header_route,
methods=["GET"],
name="route",
),
middleware=[Middleware(AddHeadersMiddleware)],
)


mounted_routes_with_route_middleware = Mount(
"/http",
routes=[
Route(
"/",
endpoint=assert_middleware_header_route,
methods=["GET"],
name="route",
middleware=[Middleware(AddHeadersMiddleware)],
),
],
)


mounted_app_with_route_middleware = Mount(
"/http",
app=Route(
"/",
endpoint=assert_middleware_header_route,
methods=["GET"],
name="route",
middleware=[Middleware(AddHeadersMiddleware)],
),
)


@pytest.mark.parametrize(
"route",
[
mounted_routes_with_middleware,
mounted_routes_with_middleware,
mounted_app_with_middleware,
mounted_routes_with_route_middleware,
mounted_app_with_route_middleware,
],
)
def test_route_level_middleware(
test_client_factory: typing.Callable[..., TestClient],
route: BaseRoute,
) -> None:
test_client = test_client_factory(Router([route]))
response = test_client.get("/http")
assert response.status_code == 200
assert response.headers["X-Test"] == "Set by middleware"


@pytest.mark.parametrize(
"route",
[
mounted_routes_with_middleware,
mounted_routes_with_middleware,
mounted_routes_with_route_middleware,
],
)
def test_mount_middleware_url_path_for_(route: BaseRoute) -> None:
"""Checks that url_path_for still works with middelware on Mounts"""
router = Router([route])
assert router.url_path_for("route") == "/http/"


def test_add_route_to_app_after_mount(
test_client_factory: typing.Callable[..., TestClient],
) -> None:
"""Checks that mounds will pick up routes
added to the underlaying app after it is mounted
"""
inner_app = Router()
app = Mount("/http", app=inner_app)
inner_app.add_route(
"/inner",
endpoint=lambda request: Response(),
methods=["GET"],
)
client = test_client_factory(app)
response = client.get("/http/inner")
assert response.status_code == 200