aws-lambda-powertools 3.0.1.dev3__py3-none-any.whl → 3.0.2.dev1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. aws_lambda_powertools/event_handler/api_gateway.py +227 -54
  2. aws_lambda_powertools/event_handler/appsync.py +343 -97
  3. aws_lambda_powertools/event_handler/bedrock_agent.py +121 -0
  4. aws_lambda_powertools/event_handler/exceptions.py +1 -1
  5. aws_lambda_powertools/event_handler/graphql_appsync/__init__.py +0 -0
  6. aws_lambda_powertools/event_handler/graphql_appsync/_registry.py +78 -0
  7. aws_lambda_powertools/event_handler/graphql_appsync/base.py +160 -0
  8. aws_lambda_powertools/event_handler/graphql_appsync/exceptions.py +10 -0
  9. aws_lambda_powertools/event_handler/graphql_appsync/router.py +62 -0
  10. aws_lambda_powertools/event_handler/middlewares/openapi_validation.py +1 -1
  11. aws_lambda_powertools/event_handler/openapi/encoders.py +94 -70
  12. aws_lambda_powertools/event_handler/openapi/exceptions.py +12 -0
  13. aws_lambda_powertools/event_handler/openapi/models.py +38 -5
  14. aws_lambda_powertools/event_handler/openapi/swagger_ui/html.py +1 -4
  15. aws_lambda_powertools/event_handler/util.py +55 -2
  16. aws_lambda_powertools/logging/utils.py +5 -5
  17. aws_lambda_powertools/metrics/provider/cloudwatch_emf/metric_properties.py +1 -0
  18. aws_lambda_powertools/shared/cookies.py +2 -2
  19. aws_lambda_powertools/shared/dynamodb_deserializer.py +13 -0
  20. aws_lambda_powertools/shared/version.py +1 -1
  21. aws_lambda_powertools/tracing/tracer.py +4 -2
  22. aws_lambda_powertools/utilities/batch/base.py +13 -4
  23. aws_lambda_powertools/utilities/data_classes/cognito_user_pool_event.py +260 -1
  24. aws_lambda_powertools/utilities/data_classes/kafka_event.py +2 -2
  25. aws_lambda_powertools/utilities/data_classes/vpc_lattice.py +7 -0
  26. aws_lambda_powertools/utilities/data_masking/base.py +4 -4
  27. aws_lambda_powertools/utilities/feature_flags/appconfig.py +21 -2
  28. aws_lambda_powertools/utilities/idempotency/idempotency.py +22 -2
  29. aws_lambda_powertools/utilities/idempotency/persistence/base.py +4 -1
  30. aws_lambda_powertools/utilities/parameters/secrets.py +1 -1
  31. aws_lambda_powertools/utilities/parser/models/__init__.py +6 -0
  32. aws_lambda_powertools/utilities/parser/models/apigw.py +12 -1
  33. aws_lambda_powertools/utilities/parser/models/apigwv2.py +7 -1
  34. aws_lambda_powertools/utilities/validation/base.py +27 -2
  35. aws_lambda_powertools/utilities/validation/validator.py +49 -4
  36. {aws_lambda_powertools-3.0.1.dev3.dist-info → aws_lambda_powertools-3.0.2.dev1.dist-info}/METADATA +12 -2
  37. {aws_lambda_powertools-3.0.1.dev3.dist-info → aws_lambda_powertools-3.0.2.dev1.dist-info}/RECORD +39 -34
  38. {aws_lambda_powertools-3.0.1.dev3.dist-info → aws_lambda_powertools-3.0.2.dev1.dist-info}/LICENSE +0 -0
  39. {aws_lambda_powertools-3.0.1.dev3.dist-info → aws_lambda_powertools-3.0.2.dev1.dist-info}/WHEEL +0 -0
@@ -1,100 +1,80 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import asyncio
3
4
  import logging
4
- from typing import TYPE_CHECKING, Any, Callable, TypeVar
5
+ import warnings
6
+ from typing import TYPE_CHECKING, Any, Callable
5
7
 
8
+ from aws_lambda_powertools.event_handler.graphql_appsync.exceptions import InvalidBatchResponse, ResolverNotFoundError
9
+ from aws_lambda_powertools.event_handler.graphql_appsync.router import Router
6
10
  from aws_lambda_powertools.utilities.data_classes import AppSyncResolverEvent
7
11
 
8
12
  if TYPE_CHECKING:
9
13
  from aws_lambda_powertools.utilities.typing import LambdaContext
10
14
 
11
- logger = logging.getLogger(__name__)
12
-
13
- AppSyncResolverEventT = TypeVar("AppSyncResolverEventT", bound=AppSyncResolverEvent)
14
-
15
+ from aws_lambda_powertools.warnings import PowertoolsUserWarning
15
16
 
16
- class BaseRouter:
17
- current_event: AppSyncResolverEventT # type: ignore[valid-type]
18
- lambda_context: LambdaContext
19
- context: dict
20
-
21
- def __init__(self):
22
- self._resolvers: dict = {}
23
-
24
- def resolver(self, type_name: str = "*", field_name: str | None = None):
25
- """Registers the resolver for field_name
26
-
27
- Parameters
28
- ----------
29
- type_name : str
30
- Type name
31
- field_name : str
32
- Field name
33
- """
34
-
35
- def register_resolver(func):
36
- logger.debug(f"Adding resolver `{func.__name__}` for field `{type_name}.{field_name}`")
37
- self._resolvers[f"{type_name}.{field_name}"] = {"func": func}
38
- return func
39
-
40
- return register_resolver
41
-
42
- def append_context(self, **additional_context):
43
- """Append key=value data as routing context"""
44
- self.context.update(**additional_context)
45
-
46
- def clear_context(self):
47
- """Resets routing context"""
48
- self.context.clear()
17
+ logger = logging.getLogger(__name__)
49
18
 
50
19
 
51
- class AppSyncResolver(BaseRouter):
20
+ class AppSyncResolver(Router):
52
21
  """
53
- AppSync resolver decorator
22
+ AppSync GraphQL API Resolver
54
23
 
55
24
  Example
56
25
  -------
57
-
58
- **Sample usage**
59
-
60
- from aws_lambda_powertools.event_handler import AppSyncResolver
61
-
62
- app = AppSyncResolver()
63
-
64
- @app.resolver(type_name="Query", field_name="listLocations")
65
- def list_locations(page: int = 0, size: int = 10) -> list:
66
- # Your logic to fetch locations with arguments passed in
67
- return [{"id": 100, "name": "Smooth Grooves"}]
68
-
69
- @app.resolver(type_name="Merchant", field_name="extraInfo")
70
- def get_extra_info() -> dict:
71
- # Can use "app.current_event.source" to filter within the parent context
72
- account_type = app.current_event.source["accountType"]
73
- method = "BTC" if account_type == "NEW" else "USD"
74
- return {"preferredPaymentMethod": method}
75
-
76
- @app.resolver(field_name="commonField")
77
- def common_field() -> str:
78
- # Would match all fieldNames matching 'commonField'
79
- return str(uuid.uuid4())
26
+ ```python
27
+ from aws_lambda_powertools.event_handler import AppSyncResolver
28
+
29
+ app = AppSyncResolver()
30
+
31
+ @app.resolver(type_name="Query", field_name="listLocations")
32
+ def list_locations(page: int = 0, size: int = 10) -> list:
33
+ # Your logic to fetch locations with arguments passed in
34
+ return [{"id": 100, "name": "Smooth Grooves"}]
35
+
36
+ @app.resolver(type_name="Merchant", field_name="extraInfo")
37
+ def get_extra_info() -> dict:
38
+ # Can use "app.current_event.source" to filter within the parent context
39
+ account_type = app.current_event.source["accountType"]
40
+ method = "BTC" if account_type == "NEW" else "USD"
41
+ return {"preferredPaymentMethod": method}
42
+
43
+ @app.resolver(field_name="commonField")
44
+ def common_field() -> str:
45
+ # Would match all fieldNames matching 'commonField'
46
+ return str(uuid.uuid4())
47
+ ```
80
48
  """
81
49
 
82
50
  def __init__(self):
51
+ """
52
+ Initialize a new instance of the AppSyncResolver.
53
+ """
83
54
  super().__init__()
84
55
  self.context = {} # early init as customers might add context before event resolution
85
56
 
86
- def resolve(
57
+ def __call__(
87
58
  self,
88
59
  event: dict,
89
60
  context: LambdaContext,
90
61
  data_model: type[AppSyncResolverEvent] = AppSyncResolverEvent,
91
62
  ) -> Any:
92
- """Resolve field_name
63
+ """Implicit lambda handler which internally calls `resolve`"""
64
+ return self.resolve(event, context, data_model)
65
+
66
+ def resolve(
67
+ self,
68
+ event: dict | list[dict],
69
+ context: LambdaContext,
70
+ data_model: type[AppSyncResolverEvent] = AppSyncResolverEvent,
71
+ ) -> Any:
72
+ """Resolves the response based on the provide event and decorator routes
93
73
 
94
74
  Parameters
95
75
  ----------
96
- event : dict
97
- Lambda event
76
+ event : dict | list[Dict]
77
+ Lambda event either coming from batch processing endpoint or from standard processing endpoint
98
78
  context : LambdaContext
99
79
  Lambda context
100
80
  data_model:
@@ -158,45 +138,214 @@ class AppSyncResolver(BaseRouter):
158
138
  ValueError
159
139
  If we could not find a field resolver
160
140
  """
161
- # Maintenance: revisit generics/overload to fix [attr-defined] in mypy usage
162
- BaseRouter.current_event = data_model(event)
163
- BaseRouter.lambda_context = context
164
141
 
165
- resolver = self._get_resolver(BaseRouter.current_event.type_name, BaseRouter.current_event.field_name)
166
- response = resolver(**BaseRouter.current_event.arguments)
142
+ self.lambda_context = context
143
+ Router.lambda_context = context
144
+
145
+ if isinstance(event, list):
146
+ Router.current_batch_event = [data_model(e) for e in event]
147
+ response = self._call_batch_resolver(event=event, data_model=data_model)
148
+ else:
149
+ Router.current_event = data_model(event)
150
+ response = self._call_single_resolver(event=event, data_model=data_model)
151
+
167
152
  self.clear_context()
168
153
 
169
154
  return response
170
155
 
171
- def _get_resolver(self, type_name: str, field_name: str) -> Callable:
172
- """Get resolver for field_name
156
+ def _call_single_resolver(self, event: dict, data_model: type[AppSyncResolverEvent]) -> Any:
157
+ """Call single event resolver
173
158
 
174
159
  Parameters
175
160
  ----------
176
- type_name : str
177
- Type name
178
- field_name : str
179
- Field name
161
+ event : dict
162
+ Event
163
+ data_model : type[AppSyncResolverEvent]
164
+ Data_model to decode AppSync event, by default it is of AppSyncResolverEvent type or subclass of it
165
+ """
166
+
167
+ logger.debug("Processing direct resolver event")
168
+
169
+ self.current_event = data_model(event)
170
+ resolver = self._resolver_registry.find_resolver(self.current_event.type_name, self.current_event.field_name)
171
+ if not resolver:
172
+ raise ValueError(f"No resolver found for '{self.current_event.type_name}.{self.current_event.field_name}'")
173
+ return resolver["func"](**self.current_event.arguments)
174
+
175
+ def _call_sync_batch_resolver(
176
+ self,
177
+ resolver: Callable,
178
+ raise_on_error: bool = False,
179
+ aggregate: bool = True,
180
+ ) -> list[Any]:
181
+ """
182
+ Calls a synchronous batch resolver function for each event in the current batch.
183
+
184
+ Parameters
185
+ ----------
186
+ resolver: Callable
187
+ The callable function to resolve events.
188
+ raise_on_error: bool
189
+ A flag indicating whether to raise an error when processing batches
190
+ with failed items. Defaults to False, which means errors are handled without raising exceptions.
191
+ aggregate: bool
192
+ A flag indicating whether the batch items should be processed at once or individually.
193
+ If True (default), the batch resolver will process all items in the batch as a single event.
194
+ If False, the batch resolver will process each item in the batch individually.
180
195
 
181
196
  Returns
182
197
  -------
183
- Callable
184
- callable function and configuration
198
+ list[Any]
199
+ A list of results corresponding to the resolved events.
185
200
  """
186
- full_name = f"{type_name}.{field_name}"
187
- resolver = self._resolvers.get(full_name, self._resolvers.get(f"*.{field_name}"))
188
- if not resolver:
189
- raise ValueError(f"No resolver found for '{full_name}'")
190
- return resolver["func"]
191
201
 
192
- def __call__(
202
+ logger.debug(f"Graceful error handling flag {raise_on_error=}")
203
+
204
+ # Checks whether the entire batch should be processed at once
205
+ if aggregate:
206
+ # Process the entire batch
207
+ response = resolver(event=self.current_batch_event)
208
+
209
+ if not isinstance(response, list):
210
+ raise InvalidBatchResponse("The response must be a List when using batch resolvers")
211
+
212
+ return response
213
+
214
+ # Non aggregated events, so we call this event list x times
215
+ # Stop on first exception we encounter
216
+ if raise_on_error:
217
+ return [
218
+ resolver(event=appconfig_event, **appconfig_event.arguments)
219
+ for appconfig_event in self.current_batch_event
220
+ ]
221
+
222
+ # By default, we gracefully append `None` for any records that failed processing
223
+ results = []
224
+ for idx, event in enumerate(self.current_batch_event):
225
+ try:
226
+ results.append(resolver(event=event, **event.arguments))
227
+ except Exception:
228
+ logger.debug(f"Failed to process event number {idx} from field '{event.info.field_name}'")
229
+ results.append(None)
230
+
231
+ return results
232
+
233
+ async def _call_async_batch_resolver(
193
234
  self,
194
- event: dict,
195
- context: LambdaContext,
196
- data_model: type[AppSyncResolverEvent] = AppSyncResolverEvent,
197
- ) -> Any:
198
- """Implicit lambda handler which internally calls `resolve`"""
199
- return self.resolve(event, context, data_model)
235
+ resolver: Callable,
236
+ raise_on_error: bool = False,
237
+ aggregate: bool = True,
238
+ ) -> list[Any]:
239
+ """
240
+ Asynchronously call a batch resolver for each event in the current batch.
241
+
242
+ Parameters
243
+ ----------
244
+ resolver: Callable
245
+ The asynchronous resolver function.
246
+ raise_on_error: bool
247
+ A flag indicating whether to raise an error when processing batches
248
+ with failed items. Defaults to False, which means errors are handled without raising exceptions.
249
+ aggregate: bool
250
+ A flag indicating whether the batch items should be processed at once or individually.
251
+ If True (default), the batch resolver will process all items in the batch as a single event.
252
+ If False, the batch resolver will process each item in the batch individually.
253
+
254
+ Returns
255
+ -------
256
+ list[Any]
257
+ A list of results corresponding to the resolved events.
258
+ """
259
+
260
+ logger.debug(f"Graceful error handling flag {raise_on_error=}")
261
+
262
+ # Checks whether the entire batch should be processed at once
263
+ if aggregate:
264
+ # Process the entire batch
265
+ ret = await resolver(event=self.current_batch_event)
266
+ if not isinstance(ret, list):
267
+ raise InvalidBatchResponse("The response must be a List when using batch resolvers")
268
+
269
+ return ret
270
+
271
+ response: list = []
272
+
273
+ # Prime coroutines
274
+ tasks = [resolver(event=e, **e.arguments) for e in self.current_batch_event]
275
+
276
+ # Aggregate results or raise at first error
277
+ if raise_on_error:
278
+ response.extend(await asyncio.gather(*tasks))
279
+ return response
280
+
281
+ # Aggregate results and exceptions, then filter them out
282
+ # Use `None` upon exception for graceful error handling at GraphQL engine level
283
+ #
284
+ # NOTE: asyncio.gather(return_exceptions=True) catches and includes exceptions in the results
285
+ # this will become useful when we support exception handling in AppSync resolver
286
+ results = await asyncio.gather(*tasks, return_exceptions=True)
287
+ response.extend(None if isinstance(ret, Exception) else ret for ret in results)
288
+
289
+ return response
290
+
291
+ def _call_batch_resolver(self, event: list[dict], data_model: type[AppSyncResolverEvent]) -> list[Any]:
292
+ """Call batch event resolver for sync and async methods
293
+
294
+ Parameters
295
+ ----------
296
+ event : list[dict]
297
+ Batch event
298
+ data_model : type[AppSyncResolverEvent]
299
+ Data_model to decode AppSync event, by default AppSyncResolverEvent or a subclass
300
+
301
+ Returns
302
+ -------
303
+ list[Any]
304
+ Results of the resolver execution.
305
+
306
+ Raises
307
+ ------
308
+ InconsistentPayloadError:
309
+ When all events in the batch do not have the same fieldName.
310
+
311
+ ResolverNotFoundError:
312
+ When no resolver is found for the specified type and field.
313
+ """
314
+ logger.debug("Processing batch resolver event")
315
+
316
+ self.current_batch_event = [data_model(e) for e in event]
317
+ type_name, field_name = self.current_batch_event[0].type_name, self.current_batch_event[0].field_name
318
+
319
+ resolver = self._batch_resolver_registry.find_resolver(type_name, field_name)
320
+ async_resolver = self._async_batch_resolver_registry.find_resolver(type_name, field_name)
321
+
322
+ if resolver and async_resolver:
323
+ warnings.warn(
324
+ f"Both synchronous and asynchronous resolvers found for the same event and field."
325
+ f"The synchronous resolver takes precedence. Executing: {resolver['func'].__name__}",
326
+ stacklevel=2,
327
+ category=PowertoolsUserWarning,
328
+ )
329
+
330
+ if resolver:
331
+ logger.debug(f"Found sync resolver. {resolver=}, {field_name=}")
332
+ return self._call_sync_batch_resolver(
333
+ resolver=resolver["func"],
334
+ raise_on_error=resolver["raise_on_error"],
335
+ aggregate=resolver["aggregate"],
336
+ )
337
+
338
+ if async_resolver:
339
+ logger.debug(f"Found async resolver. {resolver=}, {field_name=}")
340
+ return asyncio.run(
341
+ self._call_async_batch_resolver(
342
+ resolver=async_resolver["func"],
343
+ raise_on_error=async_resolver["raise_on_error"],
344
+ aggregate=async_resolver["aggregate"],
345
+ ),
346
+ )
347
+
348
+ raise ResolverNotFoundError(f"No resolver found for '{type_name}.{field_name}'")
200
349
 
201
350
  def include_router(self, router: Router) -> None:
202
351
  """Adds all resolvers defined in a router
@@ -206,15 +355,112 @@ class AppSyncResolver(BaseRouter):
206
355
  router : Router
207
356
  A router containing a dict of field resolvers
208
357
  """
358
+
209
359
  # Merge app and router context
360
+ logger.debug("Merging router and app context")
210
361
  self.context.update(**router.context)
362
+
211
363
  # use pointer to allow context clearance after event is processed e.g., resolve(evt, ctx)
212
364
  router.context = self.context
213
365
 
214
- self._resolvers.update(router._resolvers)
366
+ logger.debug("Merging router resolver registries")
367
+ self._resolver_registry.merge(router._resolver_registry)
368
+ self._batch_resolver_registry.merge(router._batch_resolver_registry)
369
+ self._async_batch_resolver_registry.merge(router._async_batch_resolver_registry)
215
370
 
371
+ def resolver(self, type_name: str = "*", field_name: str | None = None) -> Callable:
372
+ """Registers direct resolver function for GraphQL type and field name.
216
373
 
217
- class Router(BaseRouter):
218
- def __init__(self):
219
- super().__init__()
220
- self.context = {} # early init as customers might add context before event resolution
374
+ Parameters
375
+ ----------
376
+ type_name : str, optional
377
+ GraphQL type e.g., Query, Mutation, by default "*" meaning any
378
+ field_name : Optional[str], optional
379
+ GraphQL field e.g., getTodo, createTodo, by default None
380
+
381
+ Returns
382
+ -------
383
+ Callable
384
+ Registered resolver
385
+
386
+ Example
387
+ -------
388
+
389
+ ```python
390
+ from aws_lambda_powertools.event_handler import AppSyncResolver
391
+
392
+ from typing import TypedDict
393
+
394
+ app = AppSyncResolver()
395
+
396
+ class Todo(TypedDict, total=False):
397
+ id: str
398
+ userId: str
399
+ title: str
400
+ completed: bool
401
+
402
+ # resolve any GraphQL `getTodo` queries
403
+ # arguments are injected as function arguments as-is
404
+ @app.resolver(type_name="Query", field_name="getTodo")
405
+ def get_todo(id: str = "", status: str = "open") -> Todo:
406
+ todos: Response = requests.get(f"https://jsonplaceholder.typicode.com/todos/{id}")
407
+ todos.raise_for_status()
408
+
409
+ return todos.json()
410
+
411
+ def lambda_handler(event, context):
412
+ return app.resolve(event, context)
413
+ ```
414
+ """
415
+ return self._resolver_registry.register(field_name=field_name, type_name=type_name)
416
+
417
+ def batch_resolver(
418
+ self,
419
+ type_name: str = "*",
420
+ field_name: str | None = None,
421
+ raise_on_error: bool = False,
422
+ aggregate: bool = True,
423
+ ) -> Callable:
424
+ """Registers batch resolver function for GraphQL type and field name.
425
+
426
+ By default, we handle errors gracefully by returning `None`. If you want
427
+ to short-circuit and fail the entire batch use `raise_on_error=True`.
428
+
429
+ Parameters
430
+ ----------
431
+ type_name : str, optional
432
+ GraphQL type e.g., Query, Mutation, by default "*" meaning any
433
+ field_name : Optional[str], optional
434
+ GraphQL field e.g., getTodo, createTodo, by default None
435
+ raise_on_error : bool, optional
436
+ Whether to fail entire batch upon error, or handle errors gracefully (None), by default False
437
+ aggregate: bool
438
+ A flag indicating whether the batch items should be processed at once or individually.
439
+ If True (default), the batch resolver will process all items in the batch as a single event.
440
+ If False, the batch resolver will process each item in the batch individually.
441
+
442
+ Returns
443
+ -------
444
+ Callable
445
+ Registered resolver
446
+ """
447
+ return self._batch_resolver_registry.register(
448
+ field_name=field_name,
449
+ type_name=type_name,
450
+ raise_on_error=raise_on_error,
451
+ aggregate=aggregate,
452
+ )
453
+
454
+ def async_batch_resolver(
455
+ self,
456
+ type_name: str = "*",
457
+ field_name: str | None = None,
458
+ raise_on_error: bool = False,
459
+ aggregate: bool = True,
460
+ ) -> Callable:
461
+ return self._async_batch_resolver_registry.register(
462
+ field_name=field_name,
463
+ type_name=type_name,
464
+ raise_on_error=raise_on_error,
465
+ aggregate=aggregate,
466
+ )