Skip to content

Commit 34c2fa9

Browse files
Michael Brewerheitorlessa
Michael Brewer
andauthored
feat(data-classes): AppSync Resolver Event (#323)
* feat(data-classes): AppSync Resolver Event * feat(data-classes): export AppSyncResolverEvent * chore: Correct the import * chore: Fix name * feat(data-classes): Add get_header_value function * feat(data-classes): Add AppSyncIdentityCognito * tests(data-classes): Add test_get_identity_object_iam * feat(logging): Add correlation path for APP_SYNC_RESOLVER * chore: Code review changes * feat(data-classes): Add AppSyncResolverEventInfo * fix(logging): Correct paths for AppSync * tests(data-classes): Add test_appsync_resolver_direct * docs(data-classes): Add AppSync Resolver docs * chore: bump ci * feat(data-classes): Add AppSyncResolverEvent.stash * refactor(data-classes): Support direct and amplify * docs(data-classes): Correct docs * docs(data-classes): Clean up docs for review * feat(data-classes): Add AppSync resolver utilities Changes: * Add helper functions to generate GraphQL scalar types * AppSyncResolver decorator which works with AppSyncResolverEvent * feat(data-classes): Include include_event and include_context * tests(data-clasess): Verify async and yield works * test(data-classes): only run async test on new python versions * test(data-classes): Verify we can support multiple mappings * chore: Update docs/utilities/data_classes.md Co-authored-by: Heitor Lessa <[email protected]> * chore: Update docs/utilities/data_classes.md Co-authored-by: Heitor Lessa <[email protected]> * chore: Update aws_lambda_powertools/utilities/data_classes/appsync_resolver_event.py Co-authored-by: Heitor Lessa <[email protected]> * chore: Correct docs * chore: Correct docs * refactor(data-classes): AppSync location * docs(data-classes): Added sample usage * chore: fix docs rendering * refactor: Remove docstrings and relocate data class * docs(data-classes): Expanded on the scope and named app.py consistently Co-authored-by: Heitor Lessa <[email protected]>
1 parent 6a9a554 commit 34c2fa9

File tree

12 files changed

+955
-43
lines changed

12 files changed

+955
-43
lines changed

aws_lambda_powertools/logging/correlation_paths.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,6 @@
22

33
API_GATEWAY_REST = "requestContext.requestId"
44
API_GATEWAY_HTTP = API_GATEWAY_REST
5-
APPLICATION_LOAD_BALANCER = "headers.x-amzn-trace-id"
5+
APPSYNC_RESOLVER = 'request.headers."x-amzn-trace-id"'
6+
APPLICATION_LOAD_BALANCER = 'headers."x-amzn-trace-id"'
67
EVENT_BRIDGE = "id"

aws_lambda_powertools/utilities/data_classes/__init__.py

+3
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from aws_lambda_powertools.utilities.data_classes.appsync_resolver_event import AppSyncResolverEvent
2+
13
from .alb_event import ALBEvent
24
from .api_gateway_proxy_event import APIGatewayProxyEvent, APIGatewayProxyEventV2
35
from .cloud_watch_logs_event import CloudWatchLogsEvent
@@ -13,6 +15,7 @@
1315
__all__ = [
1416
"APIGatewayProxyEvent",
1517
"APIGatewayProxyEventV2",
18+
"AppSyncResolverEvent",
1619
"ALBEvent",
1720
"CloudWatchLogsEvent",
1821
"ConnectContactFlowEvent",

aws_lambda_powertools/utilities/data_classes/appsync/__init__.py

Whitespace-only changes.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import datetime
2+
import time
3+
import uuid
4+
from typing import Any, Dict
5+
6+
from aws_lambda_powertools.utilities.data_classes import AppSyncResolverEvent
7+
from aws_lambda_powertools.utilities.typing import LambdaContext
8+
9+
10+
def make_id():
11+
return str(uuid.uuid4())
12+
13+
14+
def aws_date():
15+
now = datetime.datetime.utcnow().date()
16+
return now.strftime("%Y-%m-%d")
17+
18+
19+
def aws_time():
20+
now = datetime.datetime.utcnow().time()
21+
return now.strftime("%H:%M:%S")
22+
23+
24+
def aws_datetime():
25+
now = datetime.datetime.utcnow()
26+
return now.strftime("%Y-%m-%dT%H:%M:%SZ")
27+
28+
29+
def aws_timestamp():
30+
return int(time.time())
31+
32+
33+
class AppSyncResolver:
34+
def __init__(self):
35+
self._resolvers: dict = {}
36+
37+
def resolver(
38+
self,
39+
type_name: str = "*",
40+
field_name: str = None,
41+
include_event: bool = False,
42+
include_context: bool = False,
43+
**kwargs,
44+
):
45+
def register_resolver(func):
46+
kwargs["include_event"] = include_event
47+
kwargs["include_context"] = include_context
48+
self._resolvers[f"{type_name}.{field_name}"] = {
49+
"func": func,
50+
"config": kwargs,
51+
}
52+
return func
53+
54+
return register_resolver
55+
56+
def resolve(self, event: dict, context: LambdaContext) -> Any:
57+
event = AppSyncResolverEvent(event)
58+
resolver, config = self._resolver(event.type_name, event.field_name)
59+
kwargs = self._kwargs(event, context, config)
60+
return resolver(**kwargs)
61+
62+
def _resolver(self, type_name: str, field_name: str) -> tuple:
63+
full_name = f"{type_name}.{field_name}"
64+
resolver = self._resolvers.get(full_name, self._resolvers.get(f"*.{field_name}"))
65+
if not resolver:
66+
raise ValueError(f"No resolver found for '{full_name}'")
67+
return resolver["func"], resolver["config"]
68+
69+
@staticmethod
70+
def _kwargs(event: AppSyncResolverEvent, context: LambdaContext, config: dict) -> Dict[str, Any]:
71+
kwargs = {**event.arguments}
72+
if config.get("include_event", False):
73+
kwargs["event"] = event
74+
if config.get("include_context", False):
75+
kwargs["context"] = context
76+
return kwargs
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
from typing import Any, Dict, List, Optional, Union
2+
3+
from aws_lambda_powertools.utilities.data_classes.common import DictWrapper, get_header_value
4+
5+
6+
def get_identity_object(identity: Optional[dict]) -> Any:
7+
"""Get the identity object based on the best detected type"""
8+
# API_KEY authorization
9+
if identity is None:
10+
return None
11+
12+
# AMAZON_COGNITO_USER_POOLS authorization
13+
if "sub" in identity:
14+
return AppSyncIdentityCognito(identity)
15+
16+
# AWS_IAM authorization
17+
return AppSyncIdentityIAM(identity)
18+
19+
20+
class AppSyncIdentityIAM(DictWrapper):
21+
"""AWS_IAM authorization"""
22+
23+
@property
24+
def source_ip(self) -> List[str]:
25+
"""The source IP address of the caller received by AWS AppSync. """
26+
return self["sourceIp"]
27+
28+
@property
29+
def username(self) -> str:
30+
"""The user name of the authenticated user. IAM user principal"""
31+
return self["username"]
32+
33+
@property
34+
def account_id(self) -> str:
35+
"""The AWS account ID of the caller."""
36+
return self["accountId"]
37+
38+
@property
39+
def cognito_identity_pool_id(self) -> str:
40+
"""The Amazon Cognito identity pool ID associated with the caller."""
41+
return self["cognitoIdentityPoolId"]
42+
43+
@property
44+
def cognito_identity_id(self) -> str:
45+
"""The Amazon Cognito identity ID of the caller."""
46+
return self["cognitoIdentityId"]
47+
48+
@property
49+
def user_arn(self) -> str:
50+
"""The ARN of the IAM user."""
51+
return self["userArn"]
52+
53+
@property
54+
def cognito_identity_auth_type(self) -> str:
55+
"""Either authenticated or unauthenticated based on the identity type."""
56+
return self["cognitoIdentityAuthType"]
57+
58+
@property
59+
def cognito_identity_auth_provider(self) -> str:
60+
"""A comma separated list of external identity provider information used in obtaining the
61+
credentials used to sign the request."""
62+
return self["cognitoIdentityAuthProvider"]
63+
64+
65+
class AppSyncIdentityCognito(DictWrapper):
66+
"""AMAZON_COGNITO_USER_POOLS authorization"""
67+
68+
@property
69+
def source_ip(self) -> List[str]:
70+
"""The source IP address of the caller received by AWS AppSync. """
71+
return self["sourceIp"]
72+
73+
@property
74+
def username(self) -> str:
75+
"""The user name of the authenticated user."""
76+
return self["username"]
77+
78+
@property
79+
def sub(self) -> str:
80+
"""The UUID of the authenticated user."""
81+
return self["sub"]
82+
83+
@property
84+
def claims(self) -> Dict[str, str]:
85+
"""The claims that the user has."""
86+
return self["claims"]
87+
88+
@property
89+
def default_auth_strategy(self) -> str:
90+
"""The default authorization strategy for this caller (ALLOW or DENY)."""
91+
return self["defaultAuthStrategy"]
92+
93+
@property
94+
def groups(self) -> List[str]:
95+
"""List of OIDC groups"""
96+
return self["groups"]
97+
98+
@property
99+
def issuer(self) -> str:
100+
"""The token issuer."""
101+
return self["issuer"]
102+
103+
104+
class AppSyncResolverEventInfo(DictWrapper):
105+
"""The info section contains information about the GraphQL request"""
106+
107+
@property
108+
def field_name(self) -> str:
109+
"""The name of the field that is currently being resolved."""
110+
return self["fieldName"]
111+
112+
@property
113+
def parent_type_name(self) -> str:
114+
"""The name of the parent type for the field that is currently being resolved."""
115+
return self["parentTypeName"]
116+
117+
@property
118+
def variables(self) -> Dict[str, str]:
119+
"""A map which holds all variables that are passed into the GraphQL request."""
120+
return self.get("variables")
121+
122+
@property
123+
def selection_set_list(self) -> List[str]:
124+
"""A list representation of the fields in the GraphQL selection set. Fields that are aliased will
125+
only be referenced by the alias name, not the field name."""
126+
return self.get("selectionSetList")
127+
128+
@property
129+
def selection_set_graphql(self) -> Optional[str]:
130+
"""A string representation of the selection set, formatted as GraphQL schema definition language (SDL).
131+
Although fragments are not be merged into the selection set, inline fragments are preserved."""
132+
return self.get("selectionSetGraphQL")
133+
134+
135+
class AppSyncResolverEvent(DictWrapper):
136+
"""AppSync resolver event
137+
138+
**NOTE:** AppSync Resolver Events can come in various shapes this data class
139+
supports both Amplify GraphQL directive @function and Direct Lambda Resolver
140+
141+
Documentation:
142+
-------------
143+
- https://docs.aws.amazon.com/appsync/latest/devguide/resolver-context-reference.html
144+
- https://docs.amplify.aws/cli/graphql-transformer/function#structure-of-the-function-event
145+
"""
146+
147+
def __init__(self, data: dict):
148+
super().__init__(data)
149+
150+
info: dict = data.get("info")
151+
if not info:
152+
info = {"fieldName": self.get("fieldName"), "parentTypeName": self.get("typeName")}
153+
154+
self._info = AppSyncResolverEventInfo(info)
155+
156+
@property
157+
def type_name(self) -> str:
158+
"""The name of the parent type for the field that is currently being resolved."""
159+
return self.info.parent_type_name
160+
161+
@property
162+
def field_name(self) -> str:
163+
"""The name of the field that is currently being resolved."""
164+
return self.info.field_name
165+
166+
@property
167+
def arguments(self) -> Dict[str, any]:
168+
"""A map that contains all GraphQL arguments for this field."""
169+
return self["arguments"]
170+
171+
@property
172+
def identity(self) -> Union[None, AppSyncIdentityIAM, AppSyncIdentityCognito]:
173+
"""An object that contains information about the caller.
174+
175+
Depending of the type of identify found:
176+
177+
- API_KEY authorization - returns None
178+
- AWS_IAM authorization - returns AppSyncIdentityIAM
179+
- AMAZON_COGNITO_USER_POOLS authorization - returns AppSyncIdentityCognito
180+
"""
181+
return get_identity_object(self.get("identity"))
182+
183+
@property
184+
def source(self) -> Dict[str, any]:
185+
"""A map that contains the resolution of the parent field."""
186+
return self.get("source")
187+
188+
@property
189+
def request_headers(self) -> Dict[str, str]:
190+
"""Request headers"""
191+
return self["request"]["headers"]
192+
193+
@property
194+
def prev_result(self) -> Optional[Dict[str, any]]:
195+
"""It represents the result of whatever previous operation was executed in a pipeline resolver."""
196+
prev = self.get("prev")
197+
if not prev:
198+
return None
199+
return prev.get("result")
200+
201+
@property
202+
def info(self) -> AppSyncResolverEventInfo:
203+
"""The info section contains information about the GraphQL request."""
204+
return self._info
205+
206+
@property
207+
def stash(self) -> Optional[dict]:
208+
"""The stash is a map that is made available inside each resolver and function mapping template.
209+
The same stash instance lives through a single resolver execution. This means that you can use the
210+
stash to pass arbitrary data across request and response mapping templates, and across functions in
211+
a pipeline resolver."""
212+
return self.get("stash")
213+
214+
def get_header_value(
215+
self, name: str, default_value: Optional[str] = None, case_sensitive: Optional[bool] = False
216+
) -> Optional[str]:
217+
"""Get header value by name
218+
219+
Parameters
220+
----------
221+
name: str
222+
Header name
223+
default_value: str, optional
224+
Default value if no value was found by name
225+
case_sensitive: bool
226+
Whether to use a case sensitive look up
227+
Returns
228+
-------
229+
str, optional
230+
Header value
231+
"""
232+
return get_header_value(self.request_headers, name, default_value, case_sensitive)

aws_lambda_powertools/utilities/data_classes/common.py

+16-4
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,21 @@ def get(self, key: str) -> Optional[Any]:
2020
return self._data.get(key)
2121

2222

23+
def get_header_value(headers: Dict[str, str], name: str, default_value: str, case_sensitive: bool) -> Optional[str]:
24+
"""Get header value by name"""
25+
if case_sensitive:
26+
return headers.get(name, default_value)
27+
28+
name_lower = name.lower()
29+
30+
return next(
31+
# Iterate over the dict and do a case insensitive key comparison
32+
(value for key, value in headers.items() if key.lower() == name_lower),
33+
# Default value is returned if no matches was found
34+
default_value,
35+
)
36+
37+
2338
class BaseProxyEvent(DictWrapper):
2439
@property
2540
def headers(self) -> Dict[str, str]:
@@ -72,7 +87,4 @@ def get_header_value(
7287
str, optional
7388
Header value
7489
"""
75-
if case_sensitive:
76-
return self.headers.get(name, default_value)
77-
78-
return next((value for key, value in self.headers.items() if name.lower() == key.lower()), default_value)
90+
return get_header_value(self.headers, name, default_value, case_sensitive)

docs/core/logger.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -516,7 +516,7 @@ When logging exceptions, Logger will add new keys named `exception_name` and `ex
516516
"timestamp": "2020-08-28 18:11:38,886",
517517
"service": "service_undefined",
518518
"sampling_rate": 0.0,
519-
"exception_name":"ValueError",
519+
"exception_name": "ValueError",
520520
"exception": "Traceback (most recent call last):\n File \"<input>\", line 2, in <module>\nValueError: something went wrong"
521521
}
522522
```

0 commit comments

Comments
 (0)