Skip to content

Commit 94f3fe5

Browse files
mploskiRelease botMichal Ploskileandrodamascenaheitorlessa
authored
feat(event-handler): add appsync batch resolvers (#1998)
* update changelog with latest changes * Add batch processing to appsync handler * Extend router to accept List of events. Add functional test * Add e2e tests * Add required package * Fix linter checks * Refactor appsync resolver * Refactor code to use composition instead of inheritence * Refactor appsync event handler * fix style * Add support for async batch processing * Fixing sonarcloud error * Adding docstring + increasing coverage * Adding missing test + increasing coverage * Start writing docs * Refactoring examples * Refactoring code + examples + documentation * Moving e2e tests to the right folder * Moving e2e tests to the right folder * Adding partial failure * Adding partial failure * Fixing docstring and examples * Adding documentation about Handling Exceptions * Fixing docstring * Adding fine grained control when handling exceptions * Adding fine grained control when handling exceptions * docs: add intro diagram * docs: fix wording (Tech debt) * refactor: use async_ prefix for async code * refactor: move router to a separate file to ease maintenance * refactor: rename BasePublic to BaseRouter * refactor: undo router context composition to reduce complexity and call stacks (getters) * refactor: reduce abstractions, use explicit methods over assignments Signed-off-by: heitorlessa <[email protected]> * refactor: move registry to a separate file; make it private * refactor: expand inline if for readability Signed-off-by: heitorlessa <[email protected]> * refactor: short circuit upfront, complex after Renamed long var name to `event` to make it quicker to glance through. Simplified comments by removing redundant ones. * refactor: simplify arg name * refactor: add debug statements * fix(docs): use .context instead of previous ._router.context * refactor: use kwargs for explicitness * refactor: use return_exceptions=True to reduce call stack Signed-off-by: heitorlessa <[email protected]> * chore: add notes on the beauty of return_exceptions * refactor: append suffix in exceptions * chore: if over elif in short-circuit * chore: improve logging; glad I learned this new f-string trick * chore: fix debug statement location due to null resolvers * revert: debug graceful error flag due to non-determinism async Signed-off-by: heitorlessa <[email protected]> * revert: debug stmt due to mypy; moving elsewhere Signed-off-by: heitorlessa <[email protected]> * docs: docstring resolver (tech debt) Signed-off-by: heitorlessa <[email protected]> * docs: minimal batch_resolver docstring Signed-off-by: heitorlessa <[email protected]> * chore: complete resolver docstring Signed-off-by: heitorlessa <[email protected]> * Removing payload exception * Updating poetry * Merging from develop * Addressing Heitor's feedback * Refactoring to support aggregate events * Refactoring examples + docs * Addressing Heitor's feedback * docs: add diagram to visualize n+1 problem * docs: improve wording in lambda invoke * docs: add diagram where n+1 problem shifts to Lambda runtime * docs: add diagram where n+1 problem shifts to Lambda runtime w/ error handling * docs: highlight lambda response for non-errors * docs(setup): increase table of contents depth to 5 to help redis and other subsections * Adding examples * docs: explain N+1 problem and organize content into sub-sections * docs: clean up batch resolvers section; add typing * docs: clean up no-aggregate processing section * docs: clean up raise on error section * Adding examples * docs: clean up async section * docs: fix highlights, add missing code annotation * docs: rename snippets to match advanced section * Merging from develop * Tests --------- Signed-off-by: Leandro Damascena <[email protected]> Signed-off-by: heitorlessa <[email protected]> Signed-off-by: Heitor Lessa <[email protected]> Co-authored-by: Release bot <[email protected]> Co-authored-by: Michal Ploski <[email protected]> Co-authored-by: Leandro Damascena <[email protected]> Co-authored-by: Heitor Lessa <[email protected]> Co-authored-by: Leandro Damascena <[email protected]> Co-authored-by: Heitor Lessa <[email protected]> Co-authored-by: Cavalcante Damascena <[email protected]>
1 parent ce1ea74 commit 94f3fe5

34 files changed

+2593
-133
lines changed

Diff for: aws_lambda_powertools/event_handler/appsync.py

+343-97
Large diffs are not rendered by default.

Diff for: aws_lambda_powertools/event_handler/graphql_appsync/__init__.py

Whitespace-only changes.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import logging
2+
from typing import Any, Callable, Dict, Optional
3+
4+
logger = logging.getLogger(__name__)
5+
6+
7+
class ResolverRegistry:
8+
def __init__(self):
9+
self.resolvers: Dict[str, Dict[str, Any]] = {}
10+
11+
def register(
12+
self,
13+
type_name: str = "*",
14+
field_name: Optional[str] = None,
15+
raise_on_error: bool = False,
16+
aggregate: bool = True,
17+
) -> Callable:
18+
"""Registers the resolver for field_name
19+
20+
Parameters
21+
----------
22+
type_name : str
23+
Type name
24+
field_name : str
25+
Field name
26+
raise_on_error: bool
27+
A flag indicating whether to raise an error when processing batches
28+
with failed items. Defaults to False, which means errors are handled without raising exceptions.
29+
aggregate: bool
30+
A flag indicating whether the batch items should be processed at once or individually.
31+
If True (default), the batch resolver will process all items in the batch as a single event.
32+
If False, the batch resolver will process each item in the batch individually.
33+
34+
Return
35+
----------
36+
Dict
37+
A dictionary with the resolver and if raise exception on error
38+
"""
39+
40+
def _register(func) -> Callable:
41+
logger.debug(f"Adding resolver `{func.__name__}` for field `{type_name}.{field_name}`")
42+
self.resolvers[f"{type_name}.{field_name}"] = {
43+
"func": func,
44+
"raise_on_error": raise_on_error,
45+
"aggregate": aggregate,
46+
}
47+
return func
48+
49+
return _register
50+
51+
def find_resolver(self, type_name: str, field_name: str) -> Optional[Dict]:
52+
"""Find resolver based on type_name and field_name
53+
54+
Parameters
55+
----------
56+
type_name : str
57+
Type name
58+
field_name : str
59+
Field name
60+
Return
61+
----------
62+
Optional[Dict]
63+
A dictionary with the resolver and if raise exception on error
64+
"""
65+
logger.debug(f"Looking for resolver for type={type_name}, field={field_name}.")
66+
return self.resolvers.get(f"{type_name}.{field_name}", self.resolvers.get(f"*.{field_name}"))
67+
68+
def merge(self, other_registry: "ResolverRegistry"):
69+
"""Update current registry with incoming registry
70+
71+
Parameters
72+
----------
73+
other_registry : ResolverRegistry
74+
Registry to merge from
75+
"""
76+
self.resolvers.update(**other_registry.resolvers)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
from abc import ABC, abstractmethod
2+
from typing import Callable, Optional
3+
4+
5+
class BaseRouter(ABC):
6+
"""Abstract base class for Router (resolvers)"""
7+
8+
@abstractmethod
9+
def resolver(self, type_name: str = "*", field_name: Optional[str] = None) -> Callable:
10+
"""
11+
Retrieve a resolver function for a specific type and field.
12+
13+
Parameters
14+
-----------
15+
type_name: str
16+
The name of the type.
17+
field_name: Optional[str]
18+
The name of the field (default is None).
19+
20+
Examples
21+
--------
22+
```python
23+
from typing import Optional
24+
25+
from aws_lambda_powertools.event_handler import AppSyncResolver
26+
from aws_lambda_powertools.utilities.data_classes import AppSyncResolverEvent
27+
from aws_lambda_powertools.utilities.typing import LambdaContext
28+
29+
app = AppSyncResolver()
30+
31+
@app.resolver(type_name="Query", field_name="getPost")
32+
def related_posts(event: AppSyncResolverEvent) -> Optional[list]:
33+
return {"success": "ok"}
34+
35+
def lambda_handler(event, context: LambdaContext) -> dict:
36+
return app.resolve(event, context)
37+
```
38+
39+
Returns
40+
-------
41+
Callable
42+
The resolver function.
43+
"""
44+
raise NotImplementedError
45+
46+
@abstractmethod
47+
def batch_resolver(
48+
self,
49+
type_name: str = "*",
50+
field_name: Optional[str] = None,
51+
raise_on_error: bool = False,
52+
aggregate: bool = True,
53+
) -> Callable:
54+
"""
55+
Retrieve a batch resolver function for a specific type and field.
56+
57+
Parameters
58+
-----------
59+
type_name: str
60+
The name of the type.
61+
field_name: Optional[str]
62+
The name of the field (default is None).
63+
raise_on_error: bool
64+
A flag indicating whether to raise an error when processing batches
65+
with failed items. Defaults to False, which means errors are handled without raising exceptions.
66+
aggregate: bool
67+
A flag indicating whether the batch items should be processed at once or individually.
68+
If True (default), the batch resolver will process all items in the batch as a single event.
69+
If False, the batch resolver will process each item in the batch individually.
70+
71+
Examples
72+
--------
73+
```python
74+
from typing import Optional
75+
76+
from aws_lambda_powertools.event_handler import AppSyncResolver
77+
from aws_lambda_powertools.utilities.data_classes import AppSyncResolverEvent
78+
from aws_lambda_powertools.utilities.typing import LambdaContext
79+
80+
app = AppSyncResolver()
81+
82+
@app.batch_resolver(type_name="Query", field_name="getPost")
83+
def related_posts(event: AppSyncResolverEvent, id) -> Optional[list]:
84+
return {"post_id": id}
85+
86+
def lambda_handler(event, context: LambdaContext) -> dict:
87+
return app.resolve(event, context)
88+
```
89+
90+
Returns
91+
-------
92+
Callable
93+
The batch resolver function.
94+
"""
95+
raise NotImplementedError
96+
97+
@abstractmethod
98+
def async_batch_resolver(
99+
self,
100+
type_name: str = "*",
101+
field_name: Optional[str] = None,
102+
raise_on_error: bool = False,
103+
aggregate: bool = True,
104+
) -> Callable:
105+
"""
106+
Retrieve a batch resolver function for a specific type and field and runs async.
107+
108+
Parameters
109+
-----------
110+
type_name: str
111+
The name of the type.
112+
field_name: Optional[str]
113+
The name of the field (default is None).
114+
raise_on_error: bool
115+
A flag indicating whether to raise an error when processing batches
116+
with failed items. Defaults to False, which means errors are handled without raising exceptions.
117+
aggregate: bool
118+
A flag indicating whether the batch items should be processed at once or individually.
119+
If True (default), the batch resolver will process all items in the batch as a single event.
120+
If False, the batch resolver will process each item in the batch individually.
121+
122+
Examples
123+
--------
124+
```python
125+
from typing import Optional
126+
127+
from aws_lambda_powertools.event_handler import AppSyncResolver
128+
from aws_lambda_powertools.utilities.data_classes import AppSyncResolverEvent
129+
from aws_lambda_powertools.utilities.typing import LambdaContext
130+
131+
app = AppSyncResolver()
132+
133+
@app.async_batch_resolver(type_name="Query", field_name="getPost")
134+
async def related_posts(event: AppSyncResolverEvent, id) -> Optional[list]:
135+
return {"post_id": id}
136+
137+
def lambda_handler(event, context: LambdaContext) -> dict:
138+
return app.resolve(event, context)
139+
```
140+
141+
Returns
142+
-------
143+
Callable
144+
The batch resolver function.
145+
"""
146+
raise NotImplementedError
147+
148+
@abstractmethod
149+
def append_context(self, **additional_context) -> None:
150+
"""
151+
Appends context information available under any route.
152+
153+
Parameters
154+
-----------
155+
**additional_context: dict
156+
Additional context key-value pairs to append.
157+
"""
158+
raise NotImplementedError
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
class ResolverNotFoundError(Exception):
2+
"""
3+
When a resolver is not found during a lookup.
4+
"""
5+
6+
7+
class InvalidBatchResponse(Exception):
8+
"""
9+
When a batch response something different from a List
10+
"""
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
from typing import Callable, Optional
2+
3+
from aws_lambda_powertools.event_handler.graphql_appsync._registry import ResolverRegistry
4+
from aws_lambda_powertools.event_handler.graphql_appsync.base import BaseRouter
5+
6+
7+
class Router(BaseRouter):
8+
context: dict
9+
10+
def __init__(self):
11+
self.context = {} # early init as customers might add context before event resolution
12+
self._resolver_registry = ResolverRegistry()
13+
self._batch_resolver_registry = ResolverRegistry()
14+
self._async_batch_resolver_registry = ResolverRegistry()
15+
16+
def resolver(self, type_name: str = "*", field_name: Optional[str] = None) -> Callable:
17+
return self._resolver_registry.register(field_name=field_name, type_name=type_name)
18+
19+
def batch_resolver(
20+
self,
21+
type_name: str = "*",
22+
field_name: Optional[str] = None,
23+
raise_on_error: bool = False,
24+
aggregate: bool = True,
25+
) -> Callable:
26+
return self._batch_resolver_registry.register(
27+
field_name=field_name,
28+
type_name=type_name,
29+
raise_on_error=raise_on_error,
30+
aggregate=aggregate,
31+
)
32+
33+
def async_batch_resolver(
34+
self,
35+
type_name: str = "*",
36+
field_name: Optional[str] = None,
37+
raise_on_error: bool = False,
38+
aggregate: bool = True,
39+
) -> Callable:
40+
return self._async_batch_resolver_registry.register(
41+
field_name=field_name,
42+
type_name=type_name,
43+
raise_on_error=raise_on_error,
44+
aggregate=aggregate,
45+
)
46+
47+
def append_context(self, **additional_context):
48+
"""Append key=value data as routing context"""
49+
self.context.update(**additional_context)
50+
51+
def clear_context(self):
52+
"""Resets routing context"""
53+
self.context.clear()

Diff for: aws_lambda_powertools/utilities/data_classes/appsync_resolver_event.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -184,9 +184,9 @@ def identity(self) -> Union[None, AppSyncIdentityIAM, AppSyncIdentityCognito]:
184184
return get_identity_object(self.get("identity"))
185185

186186
@property
187-
def source(self) -> Optional[Dict[str, Any]]:
187+
def source(self) -> Dict[str, Any]:
188188
"""A map that contains the resolution of the parent field."""
189-
return self.get("source")
189+
return self.get("source") or {}
190190

191191
@property
192192
def request_headers(self) -> Dict[str, str]:

Diff for: aws_lambda_powertools/utilities/parameters/secrets.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -423,7 +423,7 @@ def set_secret(
423423
>>> parameters.set_secret(
424424
name="my-secret",
425425
value='{"password": "supers3cr3tllam@passw0rd"}',
426-
client_request_token="61f2af5f-5f75-44b1-a29f-0cc37af55b11"
426+
client_request_token="YOUR_TOKEN_HERE"
427427
)
428428
429429
URLs:

0 commit comments

Comments
 (0)