Skip to content

Commit 7a58a7b

Browse files
committed
feat(event_handler): add Router support to API Gateway WebSocket resolver
include_router merges a Router's routes, global middlewares, exception handlers, and context into the resolver, so route keys can be split across files. Router handlers access the request through the router instance (router.current_event), mirroring the AppSync Events and REST resolver conventions. On route key collisions the router-registered handler wins, matching AppSync Events. Includes tests for dispatch, context pointer sharing, middleware and exception-handler merging and ordering, route-key precedence, plus docs with a two-file example.
1 parent 144b62e commit 7a58a7b

5 files changed

Lines changed: 237 additions & 0 deletions

File tree

aws_lambda_powertools/event_handler/api_gateway_websocket/websocket.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,43 @@ def exception_handler(self, exc_class: type[Exception] | list[type[Exception]])
166166
"""
167167
return self.exception_handler_manager.exception_handler(exc_class=exc_class)
168168

169+
def include_router(self, router: Router) -> None:
170+
"""
171+
Add all routes, middlewares, exception handlers, and context defined in a router.
172+
173+
Parameters
174+
----------
175+
router : Router
176+
A router containing routes to include, typically defined in a separate module
177+
178+
Examples
179+
--------
180+
>>> # orders.py
181+
>>> from aws_lambda_powertools.event_handler.api_gateway_websocket import Router
182+
>>>
183+
>>> router = Router()
184+
>>>
185+
>>> @router.route("orderUpdate")
186+
>>> def order_update():
187+
>>> return {"orderId": router.current_event.json_body["orderId"]}
188+
>>>
189+
>>> # app.py
190+
>>> import orders
191+
>>> from aws_lambda_powertools.event_handler import APIGatewayWebSocketResolver
192+
>>>
193+
>>> app = APIGatewayWebSocketResolver()
194+
>>> app.include_router(orders.router)
195+
"""
196+
logger.debug("Merging router and app context")
197+
self.context.update(**router.context)
198+
# use pointer to allow context clearance after event is processed e.g., resolve(evt, ctx)
199+
router.context = self.context
200+
201+
logger.debug("Merging router routes, middlewares, and exception handlers")
202+
self._router_middlewares.extend(router._router_middlewares)
203+
self._route_registry.merge(router._route_registry)
204+
self.exception_handler_manager.update_exception_handlers(router._exception_handlers)
205+
169206
def _resolve_route(self) -> dict[str, Any]:
170207
"""Dispatch the current event through the middleware chain to its route handler and normalize the response."""
171208
route_key = self.current_event.request_context.route_key

docs/core/event_handler/api_gateway_websocket.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,26 @@ Without a Lambda authorizer, authenticate on `$connect` with a middleware and pe
149149
3. Returning without calling `next_middleware` short-circuits the chain — the handler never runs and the connection is rejected.
150150
4. Handlers and later middlewares read it from `app.context`.
151151

152+
### Split routes with Router
153+
154+
As your API grows, group related route keys in separate files with `Router`, then include them in the resolver. `include_router` merges the router's routes, global middlewares (`use`), exception handlers, and context into the app. Inside a router file, access the request through the router instance — `router.current_event`, `router.lambda_context`, and `router.context`.
155+
156+
=== "working_with_router_orders.py"
157+
158+
```python hl_lines="3 8"
159+
--8<-- "examples/event_handler_api_gateway_websocket/src/working_with_router_orders.py"
160+
```
161+
162+
1. Use the router instance to access the current event inside router files.
163+
164+
=== "working_with_router.py"
165+
166+
```python hl_lines="1 7"
167+
--8<-- "examples/event_handler_api_gateway_websocket/src/working_with_router.py"
168+
```
169+
170+
1. Registers every route, middleware, and exception handler defined on the router.
171+
152172
### Exception handling
153173

154174
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: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import working_with_router_orders
2+
3+
from aws_lambda_powertools.event_handler import APIGatewayWebSocketResolver
4+
from aws_lambda_powertools.utilities.typing import LambdaContext
5+
6+
app = APIGatewayWebSocketResolver()
7+
app.include_router(working_with_router_orders.router) # (1)!
8+
9+
10+
@app.on_connect()
11+
def connect():
12+
return None
13+
14+
15+
def lambda_handler(event: dict, context: LambdaContext) -> dict:
16+
return app.resolve(event, context)
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
from aws_lambda_powertools.event_handler.api_gateway_websocket import Router
2+
3+
router = Router()
4+
5+
6+
@router.route("orderUpdate")
7+
def order_update():
8+
order = router.current_event.json_body # (1)!
9+
return {"orderId": order["orderId"], "status": "received"}
10+
11+
12+
@router.route("orderCancel")
13+
def order_cancel():
14+
order = router.current_event.json_body
15+
return {"orderId": order["orderId"], "status": "cancelled"}

tests/functional/event_handler/required_dependencies/api_gateway_websocket/test_api_gateway_websocket_resolver.py

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -676,3 +676,152 @@ def route_b():
676676

677677
# THEN the global middleware runs on both routes; the route-level middleware runs only on route A
678678
assert order == ["global:routeA", "route_a_mw:routeA", "handler_a", "global:routeB", "handler_b"]
679+
680+
681+
def test_included_router_routes_are_dispatched(message_event, lambda_context):
682+
# GIVEN routes registered on a standalone Router
683+
event = deepcopy(message_event)
684+
event["requestContext"]["routeKey"] = "orderUpdate"
685+
686+
router = Router()
687+
688+
@router.route("orderUpdate")
689+
def order_update():
690+
return "router handled it"
691+
692+
# WHEN the router is included and the event resolved
693+
app = APIGatewayWebSocketResolver()
694+
app.include_router(router)
695+
result = app.resolve(event, lambda_context)
696+
697+
# THEN the router-registered handler is dispatched
698+
assert result == {"statusCode": 200, "body": "router handled it"}
699+
700+
701+
def test_router_current_event_and_lambda_context_in_handler(message_event, lambda_context):
702+
# GIVEN a router handler accessing the event and context through the router instance
703+
event = deepcopy(message_event)
704+
event["requestContext"]["routeKey"] = "orderUpdate"
705+
706+
router = Router()
707+
captured = {}
708+
709+
@router.route("orderUpdate")
710+
def order_update():
711+
captured["event"] = router.current_event
712+
captured["context"] = router.lambda_context
713+
714+
app = APIGatewayWebSocketResolver()
715+
app.include_router(router)
716+
717+
# WHEN the event is resolved through the app
718+
app.resolve(event, lambda_context)
719+
720+
# THEN the router sees the same event and context as the app (class-attribute mechanism)
721+
assert isinstance(captured["event"], APIGatewayWebSocketEvent)
722+
assert captured["event"].request_context.route_key == "orderUpdate"
723+
assert captured["context"] is lambda_context
724+
725+
726+
def test_include_router_merges_and_shares_context(connect_event, lambda_context):
727+
# GIVEN context appended to a router before inclusion
728+
router = Router()
729+
router.append_context(source="router")
730+
captured = {}
731+
732+
@router.route("$connect")
733+
def connect():
734+
captured["source"] = router.context.get("source")
735+
captured["app_added"] = router.context.get("app_added")
736+
737+
app = APIGatewayWebSocketResolver()
738+
app.include_router(router)
739+
740+
# THEN the router context is merged into the app and the pointer is shared
741+
assert app.context["source"] == "router"
742+
assert router.context is app.context
743+
744+
# WHEN the app appends more context and the event is resolved
745+
app.append_context(app_added=True)
746+
app.resolve(connect_event, lambda_context)
747+
748+
# THEN the handler saw both values and clear_context cleared the shared dict
749+
assert captured == {"source": "router", "app_added": True}
750+
assert app.context == {}
751+
assert router.context == {}
752+
753+
754+
def test_router_level_use_middlewares_are_merged(connect_event, lambda_context):
755+
# GIVEN global middlewares on both the app and a router, and a route-level middleware on the router route
756+
app = APIGatewayWebSocketResolver()
757+
router = Router()
758+
order = []
759+
760+
def app_middleware(app_, next_middleware):
761+
order.append("app")
762+
return next_middleware(app_)
763+
764+
def router_middleware(app_, next_middleware):
765+
order.append("router")
766+
return next_middleware(app_)
767+
768+
def route_middleware(app_, next_middleware):
769+
order.append("route_mw")
770+
return next_middleware(app_)
771+
772+
app.use(middlewares=[app_middleware])
773+
router.use(middlewares=[router_middleware])
774+
775+
@router.route("$connect", middlewares=[route_middleware])
776+
def connect():
777+
order.append("handler")
778+
779+
# WHEN the router is included and the event resolved
780+
app.include_router(router)
781+
result = app.resolve(connect_event, lambda_context)
782+
783+
# THEN globals run in merge order (app then router), then the route-level middleware, then the handler
784+
assert result == {"statusCode": 200}
785+
assert order == ["app", "router", "route_mw", "handler"]
786+
787+
788+
def test_router_exception_handlers_are_merged(connect_event, lambda_context):
789+
# GIVEN an exception handler registered on a router
790+
router = Router()
791+
792+
@router.exception_handler(ValueError)
793+
def handle_value_error(exc: ValueError):
794+
return {"error": str(exc)}, 400
795+
796+
@router.route("$connect")
797+
def connect():
798+
raise ValueError("router boom")
799+
800+
# WHEN the router is included and the event resolved
801+
app = APIGatewayWebSocketResolver()
802+
app.include_router(router)
803+
result = app.resolve(connect_event, lambda_context)
804+
805+
# THEN the router-registered exception handler is used
806+
assert result == {"statusCode": 400, "body": '{"error":"router boom"}'}
807+
808+
809+
def test_included_router_route_wins_over_app_route(connect_event, lambda_context):
810+
# GIVEN the app and a router both registering the same route key
811+
app = APIGatewayWebSocketResolver()
812+
router = Router()
813+
814+
@app.on_connect()
815+
def app_connect():
816+
return "from app"
817+
818+
@router.route("$connect")
819+
def router_connect():
820+
return "from router"
821+
822+
# WHEN the router is included and the event resolved
823+
app.include_router(router)
824+
result = app.resolve(connect_event, lambda_context)
825+
826+
# THEN the router-registered handler takes precedence (last-wins merge semantics)
827+
assert result == {"statusCode": 200, "body": "from router"}

0 commit comments

Comments
 (0)