Skip to content

Commit 144b62e

Browse files
committed
feat(event_handler): add middleware support to API Gateway WebSocket resolver
Reuses the shared middleware framework (MiddlewareFrame, BaseMiddlewareHandler) with a WebSocket terminal adapter: global middlewares registered via use() run first, then route-level middlewares=[...], then the handler. Return values — including short-circuits that skip next_middleware — go through the same response normalization as handler returns, and middleware exceptions follow the existing exception-handling path. Global middlewares also run around the 400 for unmatched route keys, matching the REST resolver's not-found behaviour. Includes middleware tests (ordering, short-circuit, isolation across routes, exception paths, class-based middleware) and docs: Middleware plus Authentication patterns (Lambda authorizer first, then authenticate/inject_user middleware with a bring-your-own connection store).
1 parent af7dd46 commit 144b62e

8 files changed

Lines changed: 439 additions & 3 deletions

File tree

aws_lambda_powertools/event_handler/api_gateway_websocket/base.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,10 @@ def on_default(
4343
) -> Callable:
4444
raise NotImplementedError
4545

46+
@abstractmethod
47+
def use(self, middlewares: list[Callable]) -> None:
48+
raise NotImplementedError
49+
4650
def append_context(self, **additional_context) -> None:
4751
"""
4852
Appends context information available under any route.

aws_lambda_powertools/event_handler/api_gateway_websocket/router.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ def __init__(self):
5555
self.context = {} # early init as customers might add context before event resolution
5656
self._route_registry = RouteRegistry()
5757
self._exception_handlers: dict[type[Exception], Callable] = {}
58+
self._router_middlewares: list[Callable] = []
5859

5960
def route(
6061
self,
@@ -189,6 +190,33 @@ def on_default(
189190
"""
190191
return self.route(route_key=ROUTE_KEY_DEFAULT, middlewares=middlewares)
191192

193+
def use(self, middlewares: list[Callable]) -> None:
194+
"""
195+
Add one or more global middlewares that run before route-specific middlewares.
196+
197+
Middlewares run in insertion order: global middlewares first, then route-level
198+
`middlewares=[...]`, then the route handler.
199+
200+
Parameters
201+
----------
202+
middlewares : list[Callable]
203+
List of middlewares to run on every event, including unmatched route keys
204+
205+
Examples
206+
--------
207+
>>> from aws_lambda_powertools.event_handler import APIGatewayWebSocketResolver
208+
>>> from aws_lambda_powertools.event_handler.middlewares import NextMiddleware
209+
>>>
210+
>>> app = APIGatewayWebSocketResolver()
211+
>>>
212+
>>> def log_route(app: APIGatewayWebSocketResolver, next_middleware: NextMiddleware):
213+
>>> print(f"dispatching {app.current_event.request_context.route_key}")
214+
>>> return next_middleware(app)
215+
>>>
216+
>>> app.use(middlewares=[log_route])
217+
"""
218+
self._router_middlewares.extend(middlewares)
219+
192220
def exception_handler(self, exc_class: type[Exception] | list[type[Exception]]) -> Callable:
193221
"""
194222
Register a handler for one or more exception types.

aws_lambda_powertools/event_handler/api_gateway_websocket/websocket.py

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@
66
from http import HTTPStatus
77
from typing import TYPE_CHECKING, Any
88

9+
from aws_lambda_powertools.event_handler.api_gateway import MiddlewareFrame
910
from aws_lambda_powertools.event_handler.api_gateway_websocket.router import Router
11+
from aws_lambda_powertools.event_handler.api_gateway_websocket.types import WebSocketRoute
1012
from aws_lambda_powertools.event_handler.exception_handling import ExceptionHandlerManager
1113
from aws_lambda_powertools.shared.json_encoder import Encoder
1214
from aws_lambda_powertools.utilities.data_classes.api_gateway_websocket_event import APIGatewayWebSocketEvent
@@ -20,6 +22,20 @@
2022
logger = logging.getLogger(__name__)
2123

2224

25+
def _registered_route_adapter(app: APIGatewayWebSocketResolver, next_middleware: Callable[..., Any]) -> Any:
26+
"""Terminal middleware frame: calls the registered route handler (no arguments) and returns its raw value.
27+
28+
Response normalization happens once, after the whole middleware chain returns, so middlewares
29+
may short-circuit with any of the same shapes route handlers can return.
30+
"""
31+
return next_middleware()
32+
33+
34+
def _route_not_found() -> tuple[None, int]:
35+
"""Stand-in handler for unmatched route keys, so global middlewares still run around the 400."""
36+
return None, HTTPStatus.BAD_REQUEST.value
37+
38+
2339
class APIGatewayWebSocketResolver(Router):
2440
"""
2541
API Gateway WebSocket API resolver.
@@ -70,6 +86,7 @@ class APIGatewayWebSocketResolver(Router):
7086
def __init__(self):
7187
super().__init__()
7288
self.exception_handler_manager = ExceptionHandlerManager()
89+
self.processed_stack_frames: list[str] = [] # used by MiddlewareFrame for debug traces
7390

7491
def __call__(
7592
self,
@@ -110,6 +127,7 @@ def resolve(
110127
>>> return app.resolve(event, context)
111128
"""
112129
try:
130+
self.processed_stack_frames.clear()
113131
self._setup_context(event, context)
114132
return self._resolve_route()
115133
except Exception as exc:
@@ -149,7 +167,7 @@ def exception_handler(self, exc_class: type[Exception] | list[type[Exception]])
149167
return self.exception_handler_manager.exception_handler(exc_class=exc_class)
150168

151169
def _resolve_route(self) -> dict[str, Any]:
152-
"""Dispatch the current event to its route handler and normalize the response."""
170+
"""Dispatch the current event through the middleware chain to its route handler and normalize the response."""
153171
route_key = self.current_event.request_context.route_key
154172
route = self._route_registry.find_route(route_key)
155173
if route is None:
@@ -158,10 +176,26 @@ def _resolve_route(self) -> dict[str, Any]:
158176
stacklevel=2,
159177
category=PowertoolsUserWarning,
160178
)
161-
return {"statusCode": HTTPStatus.BAD_REQUEST.value}
179+
# global middlewares still run around the 400, as REST does for not-found routes
180+
route = WebSocketRoute(func=_route_not_found, middlewares=[])
162181

163182
logger.debug(f"Dispatching route key `{route_key}` to `{route['func'].__name__}`")
164-
return self._to_response(route["func"]())
183+
middleware_stack = self._build_middleware_stack(route)
184+
return self._to_response(middleware_stack(self))
185+
186+
def _build_middleware_stack(self, route: WebSocketRoute) -> Callable:
187+
"""Wrap the route handler in middleware frames: global (`use`) first, then route-level, then
188+
the terminal adapter that calls the handler. Frames are wrapped in reverse so middlewares
189+
run in the order they were added."""
190+
stack: Callable = route["func"]
191+
all_middlewares = [*self._router_middlewares, *route["middlewares"], _registered_route_adapter]
192+
for middleware in reversed(all_middlewares):
193+
stack = MiddlewareFrame(current_middleware=middleware, next_middleware=stack)
194+
return stack
195+
196+
def _push_processed_stack_frame(self, frame: str) -> None:
197+
"""Record a processed middleware frame; MiddlewareFrame calls this for debug traces."""
198+
self.processed_stack_frames.append(frame)
165199

166200
def _handle_exception(self, exc: Exception) -> dict[str, Any]:
167201
"""Resolve an exception to a response via registered handlers, or a bare 500.

docs/core/event_handler/api_gateway_websocket.md

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,56 @@ The status code has different effects depending on the route:
9999

100100
## Advanced
101101

102+
### Middleware
103+
104+
The resolver reuses the same middleware framework as the REST resolver. A middleware is a callable receiving the resolver instance and the next handler in the chain; it runs code before and/or after the route handler, and its return value goes through the same [response normalization](#response-format) as handler returns.
105+
106+
Execution order is: global middlewares (`app.use`), then route-level `middlewares=[...]`, then the route handler. Return without calling `next_middleware(app)` to short-circuit the chain; exceptions raised in middlewares follow the same [exception handling](#exception-handling) flow as handlers.
107+
108+
=== "working_with_middleware.py"
109+
110+
```python hl_lines="9 11 17 21 24"
111+
--8<-- "examples/event_handler_api_gateway_websocket/src/working_with_middleware.py"
112+
```
113+
114+
1. Middlewares receive the resolver instance and the next handler in the chain.
115+
2. Call `next_middleware(app)` to continue the chain; its return value is the route response.
116+
3. Short-circuit: the handler never runs and this value becomes the response.
117+
4. Global middlewares run on every event — including unmatched route keys — before route-level ones.
118+
5. Route-level middlewares run for this route only.
119+
120+
### Authentication patterns
121+
122+
`$connect` is the only route where credentials exist: headers and cookies are present on the WebSocket handshake only, and later messages carry nothing but the connection ID. There are two ways to bridge that gap.
123+
124+
#### Lambda authorizer
125+
126+
Prefer a Lambda authorizer on `$connect` when you can run a separate authorizer function. API Gateway persists the authorizer's output with the connection and injects it into `request_context.authorizer` on **every** invocation for that connection — a managed connection-to-identity store, no code needed in your handlers beyond reading it.
127+
128+
=== "working_with_lambda_authorizer.py"
129+
130+
```python hl_lines="9 14-15"
131+
--8<-- "examples/event_handler_api_gateway_websocket/src/working_with_lambda_authorizer.py"
132+
```
133+
134+
1. Injected by API Gateway on every route for this connection — message routes, `$default`, and `$disconnect` included.
135+
2. Authorizer context values arrive stringified (a numeric `1` arrives as `"1"`).
136+
137+
#### Authenticating with middleware
138+
139+
Without a Lambda authorizer, authenticate on `$connect` with a middleware and persist identity against the connection ID in your own store; a second middleware resolves it on message routes and shares it via `app.append_context`.
140+
141+
=== "working_with_middleware_authentication.py"
142+
143+
```python hl_lines="6 13-14 16 25 29 39"
144+
--8<-- "examples/event_handler_api_gateway_websocket/src/working_with_middleware_authentication.py"
145+
```
146+
147+
1. Bring your own connection store. The in-memory dictionary keeps this example short — real invocations for one connection can hit different Lambda environments, so use an external store such as DynamoDB with a TTL.
148+
2. Headers only exist on `$connect`.
149+
3. Returning without calling `next_middleware` short-circuits the chain — the handler never runs and the connection is rejected.
150+
4. Handlers and later middlewares read it from `app.context`.
151+
102152
### Exception handling
103153

104154
Register handlers for specific exception types with `@app.exception_handler`; it also accepts a list of types, and lookup respects inheritance. The handler receives the exception, and its return value goes through the same [response normalization](#response-format).
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
from aws_lambda_powertools.event_handler import APIGatewayWebSocketResolver
2+
from aws_lambda_powertools.utilities.typing import LambdaContext
3+
4+
app = APIGatewayWebSocketResolver()
5+
6+
7+
@app.route("orderUpdate")
8+
def order_update():
9+
identity = app.current_event.request_context.authorizer # (1)!
10+
order = app.current_event.json_body
11+
return {
12+
"orderId": order["orderId"],
13+
"status": "received",
14+
"processedFor": identity["principalId"],
15+
"tenant": identity.get("tenantId"), # (2)!
16+
}
17+
18+
19+
def lambda_handler(event: dict, context: LambdaContext) -> dict:
20+
return app.resolve(event, context)
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
from aws_lambda_powertools import Logger
2+
from aws_lambda_powertools.event_handler import APIGatewayWebSocketResolver
3+
from aws_lambda_powertools.utilities.typing import LambdaContext
4+
5+
logger = Logger()
6+
app = APIGatewayWebSocketResolver()
7+
8+
9+
def log_route(app, next_middleware): # (1)!
10+
logger.info("Dispatching", route_key=app.current_event.request_context.route_key)
11+
return next_middleware(app) # (2)!
12+
13+
14+
def enforce_order_limit(app, next_middleware):
15+
order = app.current_event.json_body
16+
if order.get("quantity", 0) > 100:
17+
return {"error": "quantity over limit"}, 400 # (3)!
18+
return next_middleware(app)
19+
20+
21+
app.use(middlewares=[log_route]) # (4)!
22+
23+
24+
@app.route("orderUpdate", middlewares=[enforce_order_limit]) # (5)!
25+
def order_update():
26+
order = app.current_event.json_body
27+
return {"orderId": order["orderId"], "status": "received"}
28+
29+
30+
def lambda_handler(event: dict, context: LambdaContext) -> dict:
31+
return app.resolve(event, context)
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
from aws_lambda_powertools.event_handler import APIGatewayWebSocketResolver
2+
from aws_lambda_powertools.utilities.typing import LambdaContext
3+
4+
app = APIGatewayWebSocketResolver()
5+
6+
sessions: dict[str, str] = {} # (1)!
7+
8+
9+
def validate_token(token: str | None) -> str | None:
10+
return "alice" if token == "Bearer valid-token" else None
11+
12+
13+
def authenticate(app, next_middleware):
14+
user = validate_token(app.current_event.headers.get("Authorization")) # (2)!
15+
if user is None:
16+
return None, 401 # (3)!
17+
sessions[app.current_event.request_context.connection_id] = user
18+
return next_middleware(app)
19+
20+
21+
def inject_user(app, next_middleware):
22+
user = sessions.get(app.current_event.request_context.connection_id)
23+
if user is None:
24+
return {"error": "unauthenticated"}, 401
25+
app.append_context(user=user) # (4)!
26+
return next_middleware(app)
27+
28+
29+
@app.on_connect(middlewares=[authenticate])
30+
def connect():
31+
return None
32+
33+
34+
@app.on_disconnect()
35+
def disconnect():
36+
sessions.pop(app.current_event.request_context.connection_id, None)
37+
38+
39+
@app.route("orderUpdate", middlewares=[inject_user])
40+
def order_update():
41+
order = app.current_event.json_body
42+
return {"orderId": order["orderId"], "status": "received", "processedFor": app.context["user"]}
43+
44+
45+
def lambda_handler(event: dict, context: LambdaContext) -> dict:
46+
return app.resolve(event, context)

0 commit comments

Comments
 (0)