Skip to content

Commit 5500dff

Browse files
authored
feat(event-handler): context support to share data between routers (#1567)
1 parent c746636 commit 5500dff

File tree

10 files changed

+309
-3
lines changed

10 files changed

+309
-3
lines changed

aws_lambda_powertools/event_handler/api_gateway.py

+24-2
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,7 @@ def build(self, event: BaseProxyEvent, cors: Optional[CORSConfig] = None) -> Dic
237237
class BaseRouter(ABC):
238238
current_event: BaseProxyEvent
239239
lambda_context: LambdaContext
240+
context: dict
240241

241242
@abstractmethod
242243
def route(
@@ -383,6 +384,14 @@ def lambda_handler(event, context):
383384
"""
384385
return self.route(rule, "PATCH", cors, compress, cache_control)
385386

387+
def append_context(self, **additional_context):
388+
"""Append key=value data as routing context"""
389+
self.context.update(**additional_context)
390+
391+
def clear_context(self):
392+
"""Resets routing context"""
393+
self.context.clear()
394+
386395

387396
class ApiGatewayResolver(BaseRouter):
388397
"""API Gateway and ALB proxy resolver
@@ -448,6 +457,7 @@ def __init__(
448457
env=os.getenv(constants.EVENT_HANDLER_DEBUG_ENV, "false"), choice=debug
449458
)
450459
self._strip_prefixes = strip_prefixes
460+
self.context: Dict = {} # early init as customers might add context before event resolution
451461

452462
# Allow for a custom serializer or a concise json serialization
453463
self._serializer = serializer or partial(json.dumps, separators=(",", ":"), cls=Encoder)
@@ -502,11 +512,17 @@ def resolve(self, event, context) -> Dict[str, Any]:
502512
"You don't need to serialize event to Event Source Data Class when using Event Handler; see issue #1152"
503513
)
504514
event = event.raw_event
515+
505516
if self._debug:
506517
print(self._json_dump(event), end="")
518+
519+
# Populate router(s) dependencies without keeping a reference to each registered router
507520
BaseRouter.current_event = self._to_proxy_event(event)
508521
BaseRouter.lambda_context = context
509-
return self._resolve().build(self.current_event, self._cors)
522+
523+
response = self._resolve().build(self.current_event, self._cors)
524+
self.clear_context()
525+
return response
510526

511527
def __call__(self, event, context) -> Any:
512528
return self.resolve(event, context)
@@ -705,7 +721,7 @@ def _json_dump(self, obj: Any) -> str:
705721
return self._serializer(obj)
706722

707723
def include_router(self, router: "Router", prefix: Optional[str] = None) -> None:
708-
"""Adds all routes defined in a router
724+
"""Adds all routes and context defined in a router
709725
710726
Parameters
711727
----------
@@ -718,6 +734,11 @@ def include_router(self, router: "Router", prefix: Optional[str] = None) -> None
718734
# Add reference to parent ApiGatewayResolver to support use cases where people subclass it to add custom logic
719735
router.api_resolver = self
720736

737+
# Merge app and router context
738+
self.context.update(**router.context)
739+
# use pointer to allow context clearance after event is processed e.g., resolve(evt, ctx)
740+
router.context = self.context
741+
721742
for route, func in router._routes.items():
722743
if prefix:
723744
rule = route[0]
@@ -733,6 +754,7 @@ class Router(BaseRouter):
733754
def __init__(self):
734755
self._routes: Dict[tuple, Callable] = {}
735756
self.api_resolver: Optional[BaseRouter] = None
757+
self.context = {} # early init as customers might add context before event resolution
736758

737759
def route(
738760
self,

aws_lambda_powertools/event_handler/appsync.py

+21-1
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
class BaseRouter:
1313
current_event: AppSyncResolverEventT # type: ignore[valid-type]
1414
lambda_context: LambdaContext
15+
context: dict
1516

1617
def __init__(self):
1718
self._resolvers: dict = {}
@@ -34,6 +35,14 @@ def register_resolver(func):
3435

3536
return register_resolver
3637

38+
def append_context(self, **additional_context):
39+
"""Append key=value data as routing context"""
40+
self.context.update(**additional_context)
41+
42+
def clear_context(self):
43+
"""Resets routing context"""
44+
self.context.clear()
45+
3746

3847
class AppSyncResolver(BaseRouter):
3948
"""
@@ -68,6 +77,7 @@ def common_field() -> str:
6877

6978
def __init__(self):
7079
super().__init__()
80+
self.context = {} # early init as customers might add context before event resolution
7181

7282
def resolve(
7383
self, event: dict, context: LambdaContext, data_model: Type[AppSyncResolverEvent] = AppSyncResolverEvent
@@ -144,8 +154,12 @@ def lambda_handler(event, context):
144154
# Maintenance: revisit generics/overload to fix [attr-defined] in mypy usage
145155
BaseRouter.current_event = data_model(event)
146156
BaseRouter.lambda_context = context
157+
147158
resolver = self._get_resolver(BaseRouter.current_event.type_name, BaseRouter.current_event.field_name)
148-
return resolver(**BaseRouter.current_event.arguments)
159+
response = resolver(**BaseRouter.current_event.arguments)
160+
self.clear_context()
161+
162+
return response
149163

150164
def _get_resolver(self, type_name: str, field_name: str) -> Callable:
151165
"""Get resolver for field_name
@@ -182,9 +196,15 @@ def include_router(self, router: "Router") -> None:
182196
router : Router
183197
A router containing a dict of field resolvers
184198
"""
199+
# Merge app and router context
200+
self.context.update(**router.context)
201+
# use pointer to allow context clearance after event is processed e.g., resolve(evt, ctx)
202+
router.context = self.context
203+
185204
self._resolvers.update(router._resolvers)
186205

187206

188207
class Router(BaseRouter):
189208
def __init__(self):
190209
super().__init__()
210+
self.context = {} # early init as customers might add context before event resolution

docs/core/event_handler/api_gateway.md

+22
Original file line numberDiff line numberDiff line change
@@ -449,6 +449,28 @@ When necessary, you can set a prefix when including a router object. This means
449449
--8<-- "examples/event_handler_rest/src/split_route_prefix_module.py"
450450
```
451451

452+
#### Sharing contextual data
453+
454+
You can use `append_context` when you want to share data between your App and Router instances. Any data you share will be available via the `context` dictionary available in your App or Router context.
455+
456+
???+ info
457+
For safety, we always clear any data available in the `context` dictionary after each invocation.
458+
459+
???+ tip
460+
This can also be useful for middlewares injecting contextual information before a request is processed.
461+
462+
=== "split_route_append_context.py"
463+
464+
```python hl_lines="18"
465+
--8<-- "examples/event_handler_rest/src/split_route_append_context.py"
466+
```
467+
468+
=== "split_route_append_context_module.py"
469+
470+
```python hl_lines="16"
471+
--8<-- "examples/event_handler_rest/src/split_route_append_context_module.py"
472+
```
473+
452474
#### Sample layout
453475

454476
This is a sample project layout for a monolithic function with routes split in different files (`/todos`, `/health`).

docs/core/event_handler/appsync.md

+22
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,28 @@ Let's assume you have `split_operation.py` as your Lambda function entrypoint an
226226
--8<-- "examples/event_handler_graphql/src/split_operation.py"
227227
```
228228

229+
#### Sharing contextual data
230+
231+
You can use `append_context` when you want to share data between your App and Router instances. Any data you share will be available via the `context` dictionary available in your App or Router context.
232+
233+
???+ info
234+
For safety, we always clear any data available in the `context` dictionary after each invocation.
235+
236+
???+ tip
237+
This can also be useful for middlewares injecting contextual information before a request is processed.
238+
239+
=== "split_route_append_context.py"
240+
241+
```python hl_lines="17"
242+
--8<-- "examples/event_handler_graphql/src/split_operation_append_context.py"
243+
```
244+
245+
=== "split_route_append_context_module.py"
246+
247+
```python hl_lines="29"
248+
--8<-- "examples/event_handler_graphql/src/split_operation_append_context_module.py"
249+
```
250+
229251
## Testing your code
230252

231253
You can test your resolvers by passing a mocked or actual AppSync Lambda event that you're expecting.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import split_operation_append_context_module
2+
3+
from aws_lambda_powertools import Logger, Tracer
4+
from aws_lambda_powertools.event_handler import AppSyncResolver
5+
from aws_lambda_powertools.logging import correlation_paths
6+
from aws_lambda_powertools.utilities.typing import LambdaContext
7+
8+
tracer = Tracer()
9+
logger = Logger()
10+
app = AppSyncResolver()
11+
app.include_router(split_operation_append_context_module.router)
12+
13+
14+
@logger.inject_lambda_context(correlation_id_path=correlation_paths.APPSYNC_RESOLVER)
15+
@tracer.capture_lambda_handler
16+
def lambda_handler(event: dict, context: LambdaContext) -> dict:
17+
app.append_context(is_admin=True) # arbitrary number of key=value data
18+
return app.resolve(event, context)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import sys
2+
3+
if sys.version_info >= (3, 8):
4+
from typing import TypedDict
5+
else:
6+
from typing_extensions import TypedDict
7+
8+
from typing import List
9+
10+
from aws_lambda_powertools import Logger, Tracer
11+
from aws_lambda_powertools.event_handler.appsync import Router
12+
13+
tracer = Tracer()
14+
logger = Logger()
15+
router = Router()
16+
17+
18+
class Location(TypedDict, total=False):
19+
id: str # noqa AA03 VNE003, required due to GraphQL Schema
20+
name: str
21+
description: str
22+
address: str
23+
24+
25+
@router.resolver(field_name="listLocations")
26+
@router.resolver(field_name="locations")
27+
@tracer.capture_method
28+
def get_locations(name: str, description: str = "") -> List[Location]: # match GraphQL Query arguments
29+
is_admin: bool = router.context.get("is_admin", False)
30+
return [{"name": name, "description": description}] if is_admin else []
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import split_route_append_context_module
2+
3+
from aws_lambda_powertools import Logger, Tracer
4+
from aws_lambda_powertools.event_handler import APIGatewayRestResolver
5+
from aws_lambda_powertools.logging import correlation_paths
6+
from aws_lambda_powertools.utilities.typing import LambdaContext
7+
8+
tracer = Tracer()
9+
logger = Logger()
10+
app = APIGatewayRestResolver()
11+
app.include_router(split_route_append_context_module.router)
12+
13+
14+
# You can continue to use other utilities just as before
15+
@logger.inject_lambda_context(correlation_id_path=correlation_paths.API_GATEWAY_REST)
16+
@tracer.capture_lambda_handler
17+
def lambda_handler(event: dict, context: LambdaContext) -> dict:
18+
app.append_context(is_admin=True) # arbitrary number of key=value data
19+
return app.resolve(event, context)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import requests
2+
from requests import Response
3+
4+
from aws_lambda_powertools import Tracer
5+
from aws_lambda_powertools.event_handler.api_gateway import Router
6+
7+
tracer = Tracer()
8+
router = Router()
9+
10+
endpoint = "https://jsonplaceholder.typicode.com/todos"
11+
12+
13+
@router.get("/todos")
14+
@tracer.capture_method
15+
def get_todos():
16+
is_admin: bool = router.context.get("is_admin", False)
17+
todos = {}
18+
19+
if is_admin:
20+
todos: Response = requests.get(endpoint)
21+
todos.raise_for_status()
22+
todos = todos.json()[:10]
23+
24+
# for brevity, we'll limit to the first 10 only
25+
return {"todos": todos}

tests/functional/event_handler/test_api_gateway.py

+63
Original file line numberDiff line numberDiff line change
@@ -1296,3 +1296,66 @@ def test_response_with_status_code_only():
12961296
assert ret.status_code == 204
12971297
assert ret.body is None
12981298
assert ret.headers == {}
1299+
1300+
1301+
def test_append_context():
1302+
app = APIGatewayRestResolver()
1303+
app.append_context(is_admin=True)
1304+
assert app.context.get("is_admin") is True
1305+
1306+
1307+
def test_router_append_context():
1308+
router = Router()
1309+
router.append_context(is_admin=True)
1310+
assert router.context.get("is_admin") is True
1311+
1312+
1313+
def test_route_context_is_cleared_after_resolve():
1314+
# GIVEN a Http API V1 proxy type event
1315+
app = APIGatewayRestResolver()
1316+
app.append_context(is_admin=True)
1317+
1318+
@app.get("/my/path")
1319+
def my_path():
1320+
return {"is_admin": app.context["is_admin"]}
1321+
1322+
# WHEN event resolution kicks in
1323+
app.resolve(LOAD_GW_EVENT, {})
1324+
1325+
# THEN context should be empty
1326+
assert app.context == {}
1327+
1328+
1329+
def test_router_has_access_to_app_context(json_dump):
1330+
# GIVEN a Router with registered routes
1331+
app = ApiGatewayResolver()
1332+
router = Router()
1333+
ctx = {"is_admin": True}
1334+
1335+
@router.get("/my/path")
1336+
def my_path():
1337+
return {"is_admin": router.context["is_admin"]}
1338+
1339+
app.include_router(router)
1340+
1341+
# WHEN context is added and event resolution kicks in
1342+
app.append_context(**ctx)
1343+
ret = app.resolve(LOAD_GW_EVENT, {})
1344+
1345+
# THEN response include initial context
1346+
assert ret["body"] == json_dump(ctx)
1347+
assert router.context == {}
1348+
1349+
1350+
def test_include_router_merges_context():
1351+
# GIVEN
1352+
app = APIGatewayRestResolver()
1353+
router = Router()
1354+
1355+
# WHEN
1356+
app.append_context(is_admin=True)
1357+
router.append_context(product_access=True)
1358+
1359+
app.include_router(router)
1360+
1361+
assert app.context == router.context

0 commit comments

Comments
 (0)