diff --git a/.github/boring-cyborg.yml b/.github/boring-cyborg.yml index 85dc7e0579d..9ccdbc363be 100644 --- a/.github/boring-cyborg.yml +++ b/.github/boring-cyborg.yml @@ -18,6 +18,30 @@ labelPRBasedOnFilePath: area/event_handlers: - aws_lambda_powertools/event_handler/* - aws_lambda_powertools/event_handler/**/* + area/middleware_factory: + - aws_lambda_powertools/middleware_factory/* + - aws_lambda_powertools/middleware_factory/**/* + area/parameters: + - aws_lambda_powertools/parameters/* + - aws_lambda_powertools/parameters/**/* + area/batch: + - aws_lambda_powertools/batch/* + - aws_lambda_powertools/batch/**/* + area/validator: + - aws_lambda_powertools/validation/* + - aws_lambda_powertools/validation/**/* + area/event_sources: + - aws_lambda_powertools/data_classes/* + - aws_lambda_powertools/data_classes/**/* + area/parser: + - aws_lambda_powertools/parser/* + - aws_lambda_powertools/parser/**/* + area/idempotency: + - aws_lambda_powertools/idempotency/* + - aws_lambda_powertools/idempotency/**/* + area/feature_flags: + - aws_lambda_powertools/feature_flags/* + - aws_lambda_powertools/feature_flags/**/* documentation: - docs/* diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 19794cad093..95296da6565 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -44,7 +44,7 @@ jobs: echo "RELEASE_TAG_VERSION=${RELEASE_TAG_VERSION:1}" >> $GITHUB_ENV - name: Ensure new version is also set in pyproject and CHANGELOG run: | - grep --regexp "\[${RELEASE_TAG_VERSION}\]" CHANGELOG.md + grep --regexp "${RELEASE_TAG_VERSION}" CHANGELOG.md grep --regexp "version \= \"${RELEASE_TAG_VERSION}\"" pyproject.toml - name: Install dependencies run: make dev diff --git a/.github/workflows/python_build.yml b/.github/workflows/python_build.yml index 7b4a1bdd77f..26fbaeb3c4e 100644 --- a/.github/workflows/python_build.yml +++ b/.github/workflows/python_build.yml @@ -16,7 +16,7 @@ jobs: strategy: max-parallel: 4 matrix: - python-version: [3.6, 3.7, 3.8] + python-version: [3.6, 3.7, 3.8, 3.9] env: OS: ${{ matrix.os }} PYTHON: ${{ matrix.python-version }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 84ff76df5d4..59c20c21747 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,48 @@ This project follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) fo ## [Unreleased] -## [1.19.0] - 2021-08-11 +## 1.20.0 - 2021-08-21 + +## Bug Fixes + +* **api-gateway:** HTTP API strip stage name from request path ([#622](https://github.com/awslabs/aws-lambda-powertools-python/issues/622)) + +### Code Refactoring + +* **event-handler:** match to match_results; 3.10 new keyword ([#616](https://github.com/awslabs/aws-lambda-powertools-python/issues/616)) + +### Documentation + +* **data-classes:** make authorizer concise; use enum ([#630](https://github.com/awslabs/aws-lambda-powertools-python/issues/630)) +* **feature-flags:** correct link and json examples ([#605](https://github.com/awslabs/aws-lambda-powertools-python/issues/605)) +* **data-class:** fix invalid syntax in new AppSync Authorizer +* **api-gateway:** add new API mapping support + +### Features + +* **data-classes:** authorizer for API Gateway HTTP and REST API ([#620](https://github.com/awslabs/aws-lambda-powertools-python/issues/620)) +* **data-classes:** new data_as_bytes property in KinesisStreamRecordPayload ([#628](https://github.com/awslabs/aws-lambda-powertools-python/issues/628)) +* **data-classes:** AppSync Lambda authorizer event ([#610](https://github.com/awslabs/aws-lambda-powertools-python/issues/610)) +* **event-handler:** prefixes to strip for custom domain mapping paths ([#579](https://github.com/awslabs/aws-lambda-powertools-python/issues/579)) +* **general:** support for Python 3.9 ([#626](https://github.com/awslabs/aws-lambda-powertools-python/issues/626)) +* **idempotency:** support for any synchronous function ([#625](https://github.com/awslabs/aws-lambda-powertools-python/issues/625)) + +### Maintenance + +* **actions:** include new labels +* **api-docs:** enable allow_reuse to fix the docs ([#612](https://github.com/awslabs/aws-lambda-powertools-python/issues/612)) +* **deps:** bump boto3 from 1.18.25 to 1.18.26 ([#627](https://github.com/awslabs/aws-lambda-powertools-python/issues/627)) +* **deps:** bump boto3 from 1.18.24 to 1.18.25 ([#623](https://github.com/awslabs/aws-lambda-powertools-python/issues/623)) +* **deps:** bump boto3 from 1.18.22 to 1.18.24 ([#619](https://github.com/awslabs/aws-lambda-powertools-python/issues/619)) +* **deps:** bump boto3 from 1.18.17 to 1.18.21 ([#608](https://github.com/awslabs/aws-lambda-powertools-python/issues/608)) +* **deps:** bump boto3 from 1.18.21 to 1.18.22 ([#614](https://github.com/awslabs/aws-lambda-powertools-python/issues/614)) +* **deps-dev:** bump flake8-comprehensions from 3.5.0 to 3.6.0 ([#609](https://github.com/awslabs/aws-lambda-powertools-python/issues/609)) +* **deps-dev:** bump flake8-comprehensions from 3.6.0 to 3.6.1 ([#615](https://github.com/awslabs/aws-lambda-powertools-python/issues/615)) +* **deps-dev:** bump mkdocs-material from 7.2.3 to 7.2.4 ([#607](https://github.com/awslabs/aws-lambda-powertools-python/issues/607)) +* **docs:** correct markdown based on markdown lint ([#603](https://github.com/awslabs/aws-lambda-powertools-python/issues/603)) +* **shared:** fix cyclic import & refactor data extraction fn ([#613](https://github.com/awslabs/aws-lambda-powertools-python/issues/613)) + +## 1.19.0 - 2021-08-11 ### Bug Fixes @@ -52,13 +93,13 @@ This project follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) fo * **deps-dev:** bump mkdocs-material from 7.2.0 to 7.2.1 ([#566](https://github.com/awslabs/aws-lambda-powertools-python/issues/566)) * **deps-dev:** bump mkdocs-material from 7.1.11 to 7.2.0 ([#551](https://github.com/awslabs/aws-lambda-powertools-python/issues/551)) * **deps-dev:** bump flake8-black from 0.2.1 to 0.2.3 ([#541](https://github.com/awslabs/aws-lambda-powertools-python/issues/541)) -## [1.18.1] - 2021-07-23 +## 1.18.1 - 2021-07-23 ### Bug Fixes * **api-gateway:** route regression for non-word and unsafe URI chars ([#556](https://github.com/awslabs/aws-lambda-powertools-python/issues/556)) -## [1.18.0] - 2021-07-20 +## 1.18.0 - 2021-07-20 ### Bug Fixes @@ -98,7 +139,7 @@ This project follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) fo * **deps-dev:** bump isort from 5.9.1 to 5.9.2 ([#514](https://github.com/awslabs/aws-lambda-powertools-python/issues/514)) * **event-handler:** adjusts API Gateway/ALB service errors exception docstrings to not confuse AppSync customers -## [1.17.1] - 2021-07-02 +## 1.17.1 - 2021-07-02 ### Bug Fixes @@ -120,7 +161,7 @@ This project follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) fo * **deps-dev:** bump isort from 5.8.0 to 5.9.1 ([#487](https://github.com/awslabs/aws-lambda-powertools-python/issues/487)) * **deps-dev:** bump mkdocs-material from 7.1.7 to 7.1.9 ([#491](https://github.com/awslabs/aws-lambda-powertools-python/issues/491)) -## [1.17.0] - 2021-06-08 +## 1.17.0 - 2021-06-08 ### Added @@ -151,24 +192,24 @@ This project follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) fo * **mergify:** disable check for matrix jobs * **mergify:** use job name to match GH Actions -## [1.16.1] - 2021-05-23 +## 1.16.1 - 2021-05-23 ### Fixed * **Parser**: Upgrade Pydantic to 1.8.2 due to CVE-2021-29510 -## [1.16.0] - 2021-05-17 +## 1.16.0 - 2021-05-17 ### Features - **data-classes(API Gateway, ALB):** New method to decode base64 encoded body ([#425](https://github.com/awslabs/aws-lambda-powertools-python/issues/425)) - **data-classes(CodePipeline):** Support for CodePipeline job event and methods to handle artifacts more easily ([#416](https://github.com/awslabs/aws-lambda-powertools-python/issues/416)) -## [1.15.1] - 2021-05-13 +## 1.15.1 - 2021-05-13 ### Fixed * **Logger**: Fix a regression with the `%s` operator -## [1.15.0] - 2021-05-06 +## 1.15.0 - 2021-05-06 ### Added @@ -186,7 +227,7 @@ This project follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) fo * **Internal**: Remove X-Ray SDK version pinning as serialization regression has been fixed in 2.8.0 * **Internal**: Latest documentation correctly includes a copy of API docs reference -## [1.14.0] - 2021-04-09 +## 1.14.0 - 2021-04-09 ### Added @@ -208,7 +249,7 @@ This project follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) fo * **Misc.**: Numerous typing fixes to better to support MyPy across all utilities * **Internal**: Downgraded poetry to 1.1.4 as there's been a regression with `importlib-metadata` in 1.1.5 not yet fixed -## [1.13.0] - 2021-03-23 +## 1.13.0 - 2021-03-23 ### Added @@ -218,7 +259,7 @@ This project follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) fo * **Docs**: Lambda Layer SAM template reference example -## [1.12.0] - 2021-03-17 +## 1.12.0 - 2021-03-17 ### Added @@ -233,7 +274,7 @@ This project follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) fo * **Tracer**: Type hint on return instance that made PyCharm no longer recognize autocompletion * **Idempotency**: Error handling for missing idempotency key and `save_in_progress` errors -## [1.11.0] - 2021-03-05 +## 1.11.0 - 2021-03-05 ### Fixed * **Tracer**: Lazy loads X-Ray SDK to increase perf by 75% for those not instantiating Tracer @@ -246,18 +287,18 @@ This project follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) fo * **Docs**: Add example on how to integrate Batch utility with Sentry.io * **Internal**: Added performance SLA tests for high level imports and Metrics validation/serialization -## [1.10.5] - 2021-02-17 +## 1.10.5 - 2021-02-17 No changes. Bumped version to trigger new pipeline build for layer publishing. -## [1.10.4] - 2021-02-17 +## 1.10.4 - 2021-02-17 ### Fixed * **Docs**: Fix anchor tags to be lower case * **Docs**: Correct the docs location for the labeller -## [1.10.3] - 2021-02-04 +## 1.10.3 - 2021-02-04 ### Added @@ -269,7 +310,7 @@ No changes. Bumped version to trigger new pipeline build for layer publishing. * **Tracer**: Disabled batching segments as X-Ray SDK does not flush traces upon reaching limits * **Parser**: Model type is now compliant with mypy -## [1.10.2] - 2021-02-04 +## 1.10.2 - 2021-02-04 ### Fixed @@ -277,13 +318,13 @@ No changes. Bumped version to trigger new pipeline build for layer publishing. * **Docs*:: Fix typos on AppConfig docstring import, and `SnsModel` typo in parser. * **Utilities**: `typing_extensions` package is now only installed in Python < 3.8 -## [1.10.1] - 2021-01-19 +## 1.10.1 - 2021-01-19 ### Fixed * **Utilities**: Added `SnsSqsEnvelope` in `parser` to dynamically adjust model mismatch when customers use SNS + SQS instead of SNS + Lambda, since we've discovered three payload keys are slightly different. -## [1.10.0] - 2021-01-18 +## 1.10.0 - 2021-01-18 ### Added - **Utilities**: Added support for AppConfig in Parameters utility @@ -299,7 +340,7 @@ No changes. Bumped version to trigger new pipeline build for layer publishing. - **Docs**: Added new environment variables for toggling features in Logger and Tracer: `POWERTOOLS_LOG_DEDUPLICATION_DISABLED`, `POWERTOOLS_TRACER_CAPTURE_RESPONSE`, `POWERTOOLS_TRACER_CAPTURE_ERROR` - **Docs**: Fixed incorrect import for Cognito data classes in Event Sources utility -## [1.9.1] - 2020-12-21 +## 1.9.1 - 2020-12-21 ### Fixed - **Logger**: Bugfix to prevent parent loggers with the same name being configured more than once @@ -309,7 +350,7 @@ No changes. Bumped version to trigger new pipeline build for layer publishing. - **Utilities**: Added equality to ease testing Event source data classes - **Package**: Added `py.typed` for initial work needed for PEP 561 compliance -## [1.9.0] - 2020-12-04 +## 1.9.0 - 2020-12-04 ### Added - **Utilities**: Added Kinesis, S3, CloudWatch Logs, Application Load Balancer, and SES support in `Parser` @@ -318,7 +359,7 @@ No changes. Bumped version to trigger new pipeline build for layer publishing. ### Fixed - **Docs**: Broken link to GitHub to homepage -## [1.8.0] - 2020-11-20 +## 1.8.0 - 2020-11-20 ### Added - **Utilities**: Added support for new EventBridge Replay field in `Parser` and `Event source data classes` @@ -330,7 +371,7 @@ No changes. Bumped version to trigger new pipeline build for layer publishing. ### Fixed - **Docs**: Fix typo in Dataclasses example for SES when fetching common email headers -## [1.7.0] - 2020-10-26 +## 1.7.0 - 2020-10-26 ### Added - **Utilities**: Add new `Parser` utility to provide parsing and deep data validation using Pydantic Models @@ -340,12 +381,12 @@ No changes. Bumped version to trigger new pipeline build for layer publishing. - **Logger**: keeps Lambda root logger handler, and add log filter instead to prevent child log records duplication - **Docs**: Improve wording on adding log keys conditionally -## [1.6.1] - 2020-09-23 +## 1.6.1 - 2020-09-23 ### Fixed - **Utilities**: Fix issue with boolean values in DynamoDB stream event data class. -## [1.6.0] - 2020-09-22 +## 1.6.0 - 2020-09-22 ### Added - **Metrics**: Support adding multiple metric values to a single metric name @@ -358,7 +399,7 @@ No changes. Bumped version to trigger new pipeline build for layer publishing. - **Docs**: Improve wording on log sampling feature in Logger, and removed duplicate content on main page - **Utilities**: Remove DeleteMessageBatch API call when there are no messages to delete -## [1.5.0] - 2020-09-04 +## 1.5.0 - 2020-09-04 ### Added - **Logger**: Add `xray_trace_id` to log output to improve integration with CloudWatch Service Lens @@ -370,7 +411,7 @@ No changes. Bumped version to trigger new pipeline build for layer publishing. ### Fixed - **Logger**: The value of `json_default` formatter is no longer written to logs -## [1.4.0] - 2020-08-25 +## 1.4.0 - 2020-08-25 ### Added - **All**: Official Lambda Layer via [Serverless Application Repository](https://serverlessrepo.aws.amazon.com/applications/eu-west-1/057560766410/aws-lambda-powertools-python-layer) @@ -385,52 +426,52 @@ No changes. Bumped version to trigger new pipeline build for layer publishing. ### Added - **Tracer**: capture_lambda_handler and capture_method decorators now support `capture_response` parameter to not include function's response as part of tracing metadata -## [1.3.1] - 2020-08-22 +## 1.3.1 - 2020-08-22 ### Fixed - **Tracer**: capture_method decorator did not properly handle nested context managers -## [1.3.0] - 2020-08-21 +## 1.3.0 - 2020-08-21 ### Added - **Utilities**: Add new `parameters` utility to retrieve a single or multiple parameters from SSM Parameter Store, Secrets Manager, DynamoDB, or your very own -## [1.2.0] - 2020-08-20 +## 1.2.0 - 2020-08-20 ### Added - **Tracer**: capture_method decorator now supports generator functions (including context managers) -## [1.1.3] - 2020-08-18 +## 1.1.3 - 2020-08-18 ### Fixed - **Logger**: Logs emitted twice, structured and unstructured, due to Lambda configuring the root handler -## [1.1.2] - 2020-08-16 +## 1.1.2 - 2020-08-16 ### Fixed - **Docs**: Clarify confusion on Tracer reuse and `auto_patch=False` statement - **Logger**: Autocomplete for log statements in PyCharm -## [1.1.1] - 2020-08-14 +## 1.1.1 - 2020-08-14 ### Fixed - **Logger**: Regression on `Logger` level not accepting `int` i.e. `Logger(level=logging.INFO)` -## [1.1.0] - 2020-08-14 +## 1.1.0 - 2020-08-14 ### Added - **Logger**: Support for logger inheritance with `child` parameter ### Fixed - **Logger**: Log level is now case insensitive via params and env var -## [1.0.2] - 2020-07-16 +## 1.0.2 - 2020-07-16 ### Fixed - **Tracer**: Correct AWS X-Ray SDK dependency to support 2.5.0 and higher -## [1.0.1] - 2020-07-06 +## 1.0.1 - 2020-07-06 ### Fixed - **Logger**: Fix a bug with `inject_lambda_context` causing existing Logger keys to be overridden if `structure_logs` was called before -## [1.0.0] - 2020-06-18 +## 1.0.0 - 2020-06-18 ### Added - **Metrics**: `add_metadata` method to add any metric metadata you'd like to ease finding metric related data via CloudWatch Logs - Set status as General Availability -## [0.11.0] - 2020-06-08 +## 0.11.0 - 2020-06-08 ### Added - Imports can now be made from top level of module, e.g.: `from aws_lambda_powertools import Logger, Metrics, Tracer` @@ -440,7 +481,7 @@ No changes. Bumped version to trigger new pipeline build for layer publishing. ### Changed - **Metrics**: No longer throws exception by default in case no metrics are emitted when using the log_metrics decorator. -## [0.10.0] - 2020-06-08 +## 0.10.0 - 2020-06-08 ### Added - **Metrics**: `capture_cold_start_metric` parameter added to `log_metrics` decorator - **Metrics**: Optional `namespace` and `service` parameters added to Metrics constructor to more closely resemble other core utils @@ -451,31 +492,31 @@ No changes. Bumped version to trigger new pipeline build for layer publishing. ### Deprecated - **Metrics**: `add_namespace` method deprecated in favor of using `namespace` parameter to Metrics constructor or `POWERTOOLS_METRICS_NAMESPACE` env var -## [0.9.5] - 2020-06-02 +## 0.9.5 - 2020-06-02 ### Fixed - **Metrics**: Coerce non-string dimension values to string - **Logger**: Correct `cold_start`, `function_memory_size` values from string to bool and int respectively -## [0.9.4] - 2020-05-29 +## 0.9.4 - 2020-05-29 ### Fixed - **Metrics**: Fix issue where metrics were not correctly flushed, and cleared on every invocation -## [0.9.3] - 2020-05-16 +## 0.9.3 - 2020-05-16 ### Fixed - **Tracer**: Fix Runtime Error for nested sync due to incorrect loop usage -## [0.9.2] - 2020-05-14 +## 0.9.2 - 2020-05-14 ### Fixed - **Tracer**: Import aiohttp lazily so it's not a hard dependency -## [0.9.0] - 2020-05-12 +## 0.9.0 - 2020-05-12 ### Added - **Tracer**: Support for async functions in `Tracer` via `capture_method` decorator - **Tracer**: Support for `aiohttp` via `aiohttp_trace_config` trace config - **Tracer**: Support for patching specific modules via `patch_modules` param - **Tracer**: Document escape hatch mechanisms via `tracer.provider` -## [0.8.1] - 2020-05-1 +## 0.8.1 - 2020-05-1 ### Fixed * **Metrics**: Fix metric unit casting logic if one passes plain string (value or key) * **Metrics:**: Fix `MetricUnit` enum values for @@ -491,7 +532,7 @@ No changes. Bumped version to trigger new pipeline build for layer publishing. - `TerabitsPerSecond` - `CountPerSecond` -## [0.8.0] - 2020-04-24 +## 0.8.0 - 2020-04-24 ### Added - **Logger**: Introduced `Logger` class for structured logging as a replacement for `logger_setup` - **Logger**: Introduced `Logger.inject_lambda_context` decorator as a replacement for `logger_inject_lambda_context` @@ -499,28 +540,28 @@ No changes. Bumped version to trigger new pipeline build for layer publishing. ### Removed - **Logger**: Raise `DeprecationWarning` exception for both `logger_setup`, `logger_inject_lambda_context` -## [0.7.0] - 2020-04-20 +## 0.7.0 - 2020-04-20 ### Added - **Middleware factory**: Introduced Middleware Factory to build your own middleware via `lambda_handler_decorator` ### Fixed - **Metrics**: Fixed metrics dimensions not being included correctly in EMF -## [0.6.3] - 2020-04-09 +## 0.6.3 - 2020-04-09 ### Fixed - **Logger**: Fix `log_metrics` decorator logic not calling the decorated function, and exception handling -## [0.6.1] - 2020-04-08 +## 0.6.1 - 2020-04-08 ### Added - **Metrics**: Introduces Metrics middleware to utilise CloudWatch Embedded Metric Format ### Deprecated - **Metrics**: Added deprecation warning for `log_metrics` -## [0.5.0] - 2020-02-20 +## 0.5.0 - 2020-02-20 ### Added - **Logger**: Introduced log sampling for debug - Thanks to [Danilo's contribution](https://github.com/awslabs/aws-lambda-powertools/pull/7) -## [0.1.0] - 2019-11-15 +## 0.1.0 - 2019-11-15 ### Added - Public beta release diff --git a/README.md b/README.md index 89889bd3a92..46a3671f93b 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ A suite of Python utilities for AWS Lambda functions to ease adopting best pract * **[Event source data classes](https://awslabs.github.io/aws-lambda-powertools-python/latest/utilities/data_classes/)** - Data classes describing the schema of common Lambda event triggers * **[Parser](https://awslabs.github.io/aws-lambda-powertools-python/latest/utilities/parser/)** - Data parsing and deep validation using Pydantic * **[Idempotency](https://awslabs.github.io/aws-lambda-powertools-python/latest/utilities/idempotency/)** - Convert your Lambda functions into idempotent operations which are safe to retry -* **[Feature Flags](./utilities/feature_flags.md)** - A simple rule engine to evaluate when one or multiple features should be enabled depending on the input +* **[Feature Flags](https://awslabs.github.io/aws-lambda-powertools-python/latest/utilities/feature_flags/)** - A simple rule engine to evaluate when one or multiple features should be enabled depending on the input ### Installation diff --git a/aws_lambda_powertools/event_handler/api_gateway.py b/aws_lambda_powertools/event_handler/api_gateway.py index 7bf364695da..c1cdde63db9 100644 --- a/aws_lambda_powertools/event_handler/api_gateway.py +++ b/aws_lambda_powertools/event_handler/api_gateway.py @@ -37,7 +37,7 @@ class ProxyEventType(Enum): ALBEvent = "ALBEvent" -class CORSConfig(object): +class CORSConfig: """CORS Config Examples @@ -265,6 +265,7 @@ def __init__( cors: Optional[CORSConfig] = None, debug: Optional[bool] = None, serializer: Optional[Callable[[Dict], str]] = None, + strip_prefixes: Optional[List[str]] = None, ): """ Parameters @@ -276,6 +277,11 @@ def __init__( debug: Optional[bool] Enables debug mode, by default False. Can be also be enabled by "POWERTOOLS_EVENT_HANDLER_DEBUG" environment variable + serializer : Callable, optional + function to serialize `obj` to a JSON formatted `str`, by default json.dumps + strip_prefixes: List[str], optional + optional list of prefixes to be removed from the request path before doing the routing. This is often used + with api gateways with multiple custom mappings. """ self._proxy_type = proxy_type self._routes: List[Route] = [] @@ -285,6 +291,7 @@ def __init__( self._debug = resolve_truthy_env_var_choice( env=os.getenv(constants.EVENT_HANDLER_DEBUG_ENV, "false"), choice=debug ) + self._strip_prefixes = strip_prefixes # Allow for a custom serializer or a concise json serialization self._serializer = serializer or partial(json.dumps, separators=(",", ":"), cls=Encoder) @@ -521,18 +528,37 @@ def _to_proxy_event(self, event: Dict) -> BaseProxyEvent: def _resolve(self) -> ResponseBuilder: """Resolves the response or return the not found response""" method = self.current_event.http_method.upper() - path = self.current_event.path + path = self._remove_prefix(self.current_event.path) for route in self._routes: if method != route.method: continue - match: Optional[re.Match] = route.rule.match(path) - if match: + match_results: Optional[re.Match] = route.rule.match(path) + if match_results: logger.debug("Found a registered route. Calling function") - return self._call_route(route, match.groupdict()) # pass fn args + return self._call_route(route, match_results.groupdict()) # pass fn args logger.debug(f"No match found for path {path} and method {method}") return self._not_found(method) + def _remove_prefix(self, path: str) -> str: + """Remove the configured prefix from the path""" + if not isinstance(self._strip_prefixes, list): + return path + + for prefix in self._strip_prefixes: + if self._path_starts_with(path, prefix): + return path[len(prefix) :] + + return path + + @staticmethod + def _path_starts_with(path: str, prefix: str): + """Returns true if the `path` starts with a prefix plus a `/`""" + if not isinstance(prefix, str) or len(prefix) == 0: + return False + + return path.startswith(prefix + "/") + def _not_found(self, method: str) -> ResponseBuilder: """Called when no matching route was found and includes support for the cors preflight response""" headers = {} diff --git a/aws_lambda_powertools/exceptions/__init__.py b/aws_lambda_powertools/exceptions/__init__.py new file mode 100644 index 00000000000..cb8724c4490 --- /dev/null +++ b/aws_lambda_powertools/exceptions/__init__.py @@ -0,0 +1,5 @@ +"""Shared exceptions that don't belong to a single utility""" + + +class InvalidEnvelopeExpressionError(Exception): + """When JMESPath fails to parse expression""" diff --git a/aws_lambda_powertools/logging/correlation_paths.py b/aws_lambda_powertools/logging/correlation_paths.py index 004aa2a59a3..b6926f08591 100644 --- a/aws_lambda_powertools/logging/correlation_paths.py +++ b/aws_lambda_powertools/logging/correlation_paths.py @@ -2,6 +2,7 @@ API_GATEWAY_REST = "requestContext.requestId" API_GATEWAY_HTTP = API_GATEWAY_REST +APPSYNC_AUTHORIZER = "requestContext.requestId" APPSYNC_RESOLVER = 'request.headers."x-amzn-trace-id"' APPLICATION_LOAD_BALANCER = 'headers."x-amzn-trace-id"' EVENT_BRIDGE = "id" diff --git a/aws_lambda_powertools/shared/constants.py b/aws_lambda_powertools/shared/constants.py index 6061462a051..622ffbce47b 100644 --- a/aws_lambda_powertools/shared/constants.py +++ b/aws_lambda_powertools/shared/constants.py @@ -16,5 +16,8 @@ XRAY_TRACE_ID_ENV: str = "_X_AMZN_TRACE_ID" LAMBDA_TASK_ROOT_ENV: str = "LAMBDA_TASK_ROOT" + +LAMBDA_FUNCTION_NAME_ENV: str = "AWS_LAMBDA_FUNCTION_NAME" + XRAY_SDK_MODULE: str = "aws_xray_sdk" XRAY_SDK_CORE_MODULE: str = "aws_xray_sdk.core" diff --git a/aws_lambda_powertools/shared/jmespath_utils.py b/aws_lambda_powertools/shared/jmespath_utils.py index f2a865d4807..9cc736aedfb 100644 --- a/aws_lambda_powertools/shared/jmespath_utils.py +++ b/aws_lambda_powertools/shared/jmespath_utils.py @@ -1,13 +1,15 @@ import base64 import gzip import json +import logging from typing import Any, Dict, Optional, Union import jmespath from jmespath.exceptions import LexerError -from aws_lambda_powertools.utilities.validation import InvalidEnvelopeExpressionError -from aws_lambda_powertools.utilities.validation.base import logger +from aws_lambda_powertools.exceptions import InvalidEnvelopeExpressionError + +logger = logging.getLogger(__name__) class PowertoolsFunctions(jmespath.functions.Functions): @@ -27,7 +29,7 @@ def _func_powertools_base64_gzip(self, value): return uncompressed.decode() -def unwrap_event_from_envelope(data: Union[Dict, str], envelope: str, jmespath_options: Optional[Dict]) -> Any: +def extract_data_from_envelope(data: Union[Dict, str], envelope: str, jmespath_options: Optional[Dict]) -> Any: """Searches data using JMESPath expression Parameters diff --git a/aws_lambda_powertools/shared/types.py b/aws_lambda_powertools/shared/types.py new file mode 100644 index 00000000000..c5c91535bd3 --- /dev/null +++ b/aws_lambda_powertools/shared/types.py @@ -0,0 +1,3 @@ +from typing import Any, Callable, TypeVar + +AnyCallableT = TypeVar("AnyCallableT", bound=Callable[..., Any]) # noqa: VNE001 diff --git a/aws_lambda_powertools/tracing/tracer.py b/aws_lambda_powertools/tracing/tracer.py index e57d24044dc..dc010a3712f 100644 --- a/aws_lambda_powertools/tracing/tracer.py +++ b/aws_lambda_powertools/tracing/tracer.py @@ -5,11 +5,12 @@ import logging import numbers import os -from typing import Any, Awaitable, Callable, Dict, Optional, Sequence, TypeVar, Union, cast, overload +from typing import Any, Callable, Dict, Optional, Sequence, Union, cast, overload from ..shared import constants from ..shared.functions import resolve_env_var_choice, resolve_truthy_env_var_choice from ..shared.lazy_import import LazyLoader +from ..shared.types import AnyCallableT from .base import BaseProvider, BaseSegment is_cold_start = True @@ -18,9 +19,6 @@ aws_xray_sdk = LazyLoader(constants.XRAY_SDK_MODULE, globals(), constants.XRAY_SDK_MODULE) aws_xray_sdk.core = LazyLoader(constants.XRAY_SDK_CORE_MODULE, globals(), constants.XRAY_SDK_CORE_MODULE) -AnyCallableT = TypeVar("AnyCallableT", bound=Callable[..., Any]) # noqa: VNE001 -AnyAwaitableT = TypeVar("AnyAwaitableT", bound=Awaitable) - class Tracer: """Tracer using AWS-XRay to provide decorators with known defaults for Lambda functions diff --git a/aws_lambda_powertools/utilities/data_classes/api_gateway_authorizer_event.py b/aws_lambda_powertools/utilities/data_classes/api_gateway_authorizer_event.py new file mode 100644 index 00000000000..29694eacd97 --- /dev/null +++ b/aws_lambda_powertools/utilities/data_classes/api_gateway_authorizer_event.py @@ -0,0 +1,494 @@ +import enum +import re +from typing import Any, Dict, List, Optional + +from aws_lambda_powertools.utilities.data_classes.common import ( + BaseRequestContext, + BaseRequestContextV2, + DictWrapper, + get_header_value, +) + + +class APIGatewayRouteArn: + """A parsed route arn""" + + def __init__( + self, + region: str, + aws_account_id: str, + api_id: str, + stage: str, + http_method: str, + resource: str, + ): + self.partition = "aws" + self.region = region + self.aws_account_id = aws_account_id + self.api_id = api_id + self.stage = stage + self.http_method = http_method + self.resource = resource + + @property + def arn(self) -> str: + """Build an arn from it's parts + eg: arn:aws:execute-api:us-east-1:123456789012:abcdef123/test/GET/request""" + return ( + f"arn:{self.partition}:execute-api:{self.region}:{self.aws_account_id}:{self.api_id}/{self.stage}/" + f"{self.http_method}/{self.resource}" + ) + + +def parse_api_gateway_arn(arn: str) -> APIGatewayRouteArn: + """Parses a gateway route arn as a APIGatewayRouteArn class + + Parameters + ---------- + arn : str + ARN string for a methodArn or a routeArn + Returns + ------- + APIGatewayRouteArn + """ + arn_parts = arn.split(":") + api_gateway_arn_parts = arn_parts[5].split("/") + return APIGatewayRouteArn( + region=arn_parts[3], + aws_account_id=arn_parts[4], + api_id=api_gateway_arn_parts[0], + stage=api_gateway_arn_parts[1], + http_method=api_gateway_arn_parts[2], + resource=api_gateway_arn_parts[3] if len(api_gateway_arn_parts) == 4 else "", + ) + + +class APIGatewayAuthorizerTokenEvent(DictWrapper): + """API Gateway Authorizer Token Event Format 1.0 + + Documentation: + ------------- + - https://docs.aws.amazon.com/apigateway/latest/developerguide/apigateway-use-lambda-authorizer.html + """ + + @property + def get_type(self) -> str: + return self["type"] + + @property + def authorization_token(self) -> str: + return self["authorizationToken"] + + @property + def method_arn(self) -> str: + """ARN of the incoming method request and is populated by API Gateway in accordance with the Lambda authorizer + configuration""" + return self["methodArn"] + + @property + def parsed_arn(self) -> APIGatewayRouteArn: + """Convenient property to return a parsed api gateway method arn""" + return parse_api_gateway_arn(self.method_arn) + + +class APIGatewayAuthorizerRequestEvent(DictWrapper): + """API Gateway Authorizer Request Event Format 1.0 + + Documentation: + ------------- + - https://docs.aws.amazon.com/apigateway/latest/developerguide/apigateway-use-lambda-authorizer.html + - https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-lambda-authorizer.html + """ + + @property + def version(self) -> str: + return self["version"] + + @property + def get_type(self) -> str: + return self["type"] + + @property + def method_arn(self) -> str: + return self["methodArn"] + + @property + def parsed_arn(self) -> APIGatewayRouteArn: + return parse_api_gateway_arn(self.method_arn) + + @property + def identity_source(self) -> str: + return self["identitySource"] + + @property + def authorization_token(self) -> str: + return self["authorizationToken"] + + @property + def resource(self) -> str: + return self["resource"] + + @property + def path(self) -> str: + return self["path"] + + @property + def http_method(self) -> str: + return self["httpMethod"] + + @property + def headers(self) -> Dict[str, str]: + return self["headers"] + + @property + def query_string_parameters(self) -> Dict[str, str]: + return self["queryStringParameters"] + + @property + def path_parameters(self) -> Dict[str, str]: + return self["pathParameters"] + + @property + def stage_variables(self) -> Dict[str, str]: + return self["stageVariables"] + + @property + def request_context(self) -> BaseRequestContext: + return BaseRequestContext(self._data) + + def get_header_value( + self, name: str, default_value: Optional[str] = None, case_sensitive: Optional[bool] = False + ) -> Optional[str]: + """Get header value by name + + Parameters + ---------- + name: str + Header name + default_value: str, optional + Default value if no value was found by name + case_sensitive: bool + Whether to use a case sensitive look up + Returns + ------- + str, optional + Header value + """ + return get_header_value(self.headers, name, default_value, case_sensitive) + + +class APIGatewayAuthorizerEventV2(DictWrapper): + """API Gateway Authorizer Event Format 2.0 + + Documentation: + ------------- + - https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-lambda-authorizer.html + - https://aws.amazon.com/blogs/compute/introducing-iam-and-lambda-authorizers-for-amazon-api-gateway-http-apis/ + """ + + @property + def version(self) -> str: + """Event payload version should always be 2.0""" + return self["version"] + + @property + def get_type(self) -> str: + """Event type should always be request""" + return self["type"] + + @property + def route_arn(self) -> str: + """ARN of the route being called + + eg: arn:aws:execute-api:us-east-1:123456789012:abcdef123/test/GET/request""" + return self["routeArn"] + + @property + def parsed_arn(self) -> APIGatewayRouteArn: + """Convenient property to return a parsed api gateway route arn""" + return parse_api_gateway_arn(self.route_arn) + + @property + def identity_source(self) -> Optional[List[str]]: + """The identity source for which authorization is requested. + + For a REQUEST authorizer, this is optional. The value is a set of one or more mapping expressions of the + specified request parameters. The identity source can be headers, query string parameters, stage variables, + and context parameters. + """ + return self.get("identitySource") + + @property + def route_key(self) -> str: + """The route key for the route. For HTTP APIs, the route key can be either $default, + or a combination of an HTTP method and resource path, for example, GET /pets.""" + return self["routeKey"] + + @property + def raw_path(self) -> str: + return self["rawPath"] + + @property + def raw_query_string(self) -> str: + return self["rawQueryString"] + + @property + def cookies(self) -> List[str]: + return self["cookies"] + + @property + def headers(self) -> Dict[str, str]: + return self["headers"] + + @property + def query_string_parameters(self) -> Dict[str, str]: + return self["queryStringParameters"] + + @property + def request_context(self) -> BaseRequestContextV2: + return BaseRequestContextV2(self._data) + + @property + def path_parameters(self) -> Optional[Dict[str, str]]: + return self.get("pathParameters") + + @property + def stage_variables(self) -> Optional[Dict[str, str]]: + return self.get("stageVariables") + + def get_header_value( + self, name: str, default_value: Optional[str] = None, case_sensitive: Optional[bool] = False + ) -> Optional[str]: + """Get header value by name + + Parameters + ---------- + name: str + Header name + default_value: str, optional + Default value if no value was found by name + case_sensitive: bool + Whether to use a case sensitive look up + Returns + ------- + str, optional + Header value + """ + return get_header_value(self.headers, name, default_value, case_sensitive) + + +class APIGatewayAuthorizerResponseV2: + """Api Gateway HTTP API V2 payload authorizer simple response helper + + Parameters + ---------- + authorize: bool + authorize is a boolean value indicating if the value in authorizationToken + is authorized to make calls to the GraphQL API. If this value is + true, execution of the GraphQL API continues. If this value is false, + an UnauthorizedException is raised + context: Dict[str, Any], optional + A JSON object visible as `event.requestContext.authorizer` lambda event + + The context object only supports key-value pairs. Nested keys are not supported. + + Warning: The total size of this JSON object must not exceed 5MB. + """ + + def __init__( + self, + authorize: bool = False, + context: Optional[Dict[str, Any]] = None, + ): + self.authorize = authorize + self.context = context + + def asdict(self) -> dict: + """Return the response as a dict""" + response: Dict = {"isAuthorized": self.authorize} + + if self.context: + response["context"] = self.context + + return response + + +class HttpVerb(enum.Enum): + GET = "GET" + POST = "POST" + PUT = "PUT" + PATCH = "PATCH" + HEAD = "HEAD" + DELETE = "DELETE" + OPTIONS = "OPTIONS" + ALL = "*" + + +class APIGatewayAuthorizerResponse: + """Api Gateway HTTP API V1 payload or Rest api authorizer response helper + + Based on: - https://github.com/awslabs/aws-apigateway-lambda-authorizer-blueprints/blob/\ + master/blueprints/python/api-gateway-authorizer-python.py + """ + + version = "2012-10-17" + """The policy version used for the evaluation. This should always be '2012-10-17'""" + + path_regex = r"^[/.a-zA-Z0-9-\*]+$" + """The regular expression used to validate resource paths for the policy""" + + def __init__( + self, + principal_id: str, + region: str, + aws_account_id: str, + api_id: str, + stage: str, + context: Optional[Dict] = None, + ): + """ + Parameters + ---------- + principal_id : str + The principal used for the policy, this should be a unique identifier for the end user + region : str + AWS Regions. Beware of using '*' since it will not simply mean any region, because stars will greedily + expand over '/' or other separators. + See https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_elements_resource.html for more + details. + aws_account_id : str + The AWS account id the policy will be generated for. This is used to create the method ARNs. + api_id : str + The API Gateway API id to be used in the policy. + Beware of using '*' since it will not simply mean any API Gateway API id, because stars will greedily + expand over '/' or other separators. + See https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_elements_resource.html for more + details. + stage : str + The default stage to be used in the policy. + Beware of using '*' since it will not simply mean any stage, because stars will + greedily expand over '/' or other separators. + See https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_elements_resource.html for more + details. + context : Dict, optional + Optional, context. + Note: only names of type string and values of type int, string or boolean are supported + """ + self.principal_id = principal_id + self.region = region + self.aws_account_id = aws_account_id + self.api_id = api_id + self.stage = stage + self.context = context + self._allow_routes: List[Dict] = [] + self._deny_routes: List[Dict] = [] + + def _add_route(self, effect: str, verb: str, resource: str, conditions: List[Dict]): + """Adds a route to the internal lists of allowed or denied routes. Each object in + the internal list contains a resource ARN and a condition statement. The condition + statement can be null.""" + if verb != "*" and verb not in HttpVerb.__members__: + allowed_values = [verb.value for verb in HttpVerb] + raise ValueError(f"Invalid HTTP verb: '{verb}'. Use either '{allowed_values}'") + + resource_pattern = re.compile(self.path_regex) + if not resource_pattern.match(resource): + raise ValueError(f"Invalid resource path: {resource}. Path should match {self.path_regex}") + + if resource[:1] == "/": + resource = resource[1:] + + resource_arn = APIGatewayRouteArn(self.region, self.aws_account_id, self.api_id, self.stage, verb, resource).arn + + route = {"resourceArn": resource_arn, "conditions": conditions} + + if effect.lower() == "allow": + self._allow_routes.append(route) + else: # deny + self._deny_routes.append(route) + + @staticmethod + def _get_empty_statement(effect: str) -> Dict[str, Any]: + """Returns an empty statement object prepopulated with the correct action and the desired effect.""" + return {"Action": "execute-api:Invoke", "Effect": effect.capitalize(), "Resource": []} + + def _get_statement_for_effect(self, effect: str, methods: List) -> List: + """This function loops over an array of objects containing a resourceArn and + conditions statement and generates the array of statements for the policy.""" + if len(methods) == 0: + return [] + + statements = [] + + statement = self._get_empty_statement(effect) + for method in methods: + if method["conditions"] is None or len(method["conditions"]) == 0: + statement["Resource"].append(method["resourceArn"]) + else: + conditional_statement = self._get_empty_statement(effect) + conditional_statement["Resource"].append(method["resourceArn"]) + conditional_statement["Condition"] = method["conditions"] + statements.append(conditional_statement) + + if len(statement["Resource"]) > 0: + statements.append(statement) + + return statements + + def allow_all_routes(self, http_method: str = HttpVerb.ALL.value): + """Adds a '*' allow to the policy to authorize access to all methods of an API + + Parameters + ---------- + http_method: str + """ + self._add_route(effect="Allow", verb=http_method, resource="*", conditions=[]) + + def deny_all_routes(self, http_method: str = HttpVerb.ALL.value): + """Adds a '*' allow to the policy to deny access to all methods of an API + + Parameters + ---------- + http_method: str + """ + + self._add_route(effect="Deny", verb=http_method, resource="*", conditions=[]) + + def allow_route(self, http_method: str, resource: str, conditions: Optional[List[Dict]] = None): + """Adds an API Gateway method (Http verb + Resource path) to the list of allowed + methods for the policy. + + Optionally includes a condition for the policy statement. More on AWS policy + conditions here: https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_elements.html#Condition""" + conditions = conditions or [] + self._add_route(effect="Allow", verb=http_method, resource=resource, conditions=conditions) + + def deny_route(self, http_method: str, resource: str, conditions: Optional[List[Dict]] = None): + """Adds an API Gateway method (Http verb + Resource path) to the list of denied + methods for the policy. + + Optionally includes a condition for the policy statement. More on AWS policy + conditions here: https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_elements.html#Condition""" + conditions = conditions or [] + self._add_route(effect="Deny", verb=http_method, resource=resource, conditions=conditions) + + def asdict(self) -> Dict[str, Any]: + """Generates the policy document based on the internal lists of allowed and denied + conditions. This will generate a policy with two main statements for the effect: + one statement for Allow and one statement for Deny. + Methods that includes conditions will have their own statement in the policy.""" + if len(self._allow_routes) == 0 and len(self._deny_routes) == 0: + raise ValueError("No statements defined for the policy") + + response: Dict[str, Any] = { + "principalId": self.principal_id, + "policyDocument": {"Version": self.version, "Statement": []}, + } + + response["policyDocument"]["Statement"].extend(self._get_statement_for_effect("Allow", self._allow_routes)) + response["policyDocument"]["Statement"].extend(self._get_statement_for_effect("Deny", self._deny_routes)) + + if self.context: + response["context"] = self.context + + return response diff --git a/aws_lambda_powertools/utilities/data_classes/api_gateway_proxy_event.py b/aws_lambda_powertools/utilities/data_classes/api_gateway_proxy_event.py index 1ce6a742125..34ac8d83993 100644 --- a/aws_lambda_powertools/utilities/data_classes/api_gateway_proxy_event.py +++ b/aws_lambda_powertools/utilities/data_classes/api_gateway_proxy_event.py @@ -1,82 +1,11 @@ from typing import Any, Dict, List, Optional -from aws_lambda_powertools.utilities.data_classes.common import BaseProxyEvent, DictWrapper - - -class APIGatewayEventIdentity(DictWrapper): - @property - def access_key(self) -> Optional[str]: - return self["requestContext"]["identity"].get("accessKey") - - @property - def account_id(self) -> Optional[str]: - """The AWS account ID associated with the request.""" - return self["requestContext"]["identity"].get("accountId") - - @property - def api_key(self) -> Optional[str]: - """For API methods that require an API key, this variable is the API key associated with the method request. - For methods that don't require an API key, this variable is null.""" - return self["requestContext"]["identity"].get("apiKey") - - @property - def api_key_id(self) -> Optional[str]: - """The API key ID associated with an API request that requires an API key.""" - return self["requestContext"]["identity"].get("apiKeyId") - - @property - def caller(self) -> Optional[str]: - """The principal identifier of the caller making the request.""" - return self["requestContext"]["identity"].get("caller") - - @property - def cognito_authentication_provider(self) -> Optional[str]: - """A comma-separated list of the Amazon Cognito authentication providers used by the caller - making the request. Available only if the request was signed with Amazon Cognito credentials.""" - return self["requestContext"]["identity"].get("cognitoAuthenticationProvider") - - @property - def cognito_authentication_type(self) -> Optional[str]: - """The Amazon Cognito authentication type of the caller making the request. - Available only if the request was signed with Amazon Cognito credentials.""" - return self["requestContext"]["identity"].get("cognitoAuthenticationType") - - @property - def cognito_identity_id(self) -> Optional[str]: - """The Amazon Cognito identity ID of the caller making the request. - Available only if the request was signed with Amazon Cognito credentials.""" - return self["requestContext"]["identity"].get("cognitoIdentityId") - - @property - def cognito_identity_pool_id(self) -> Optional[str]: - """The Amazon Cognito identity pool ID of the caller making the request. - Available only if the request was signed with Amazon Cognito credentials.""" - return self["requestContext"]["identity"].get("cognitoIdentityPoolId") - - @property - def principal_org_id(self) -> Optional[str]: - """The AWS organization ID.""" - return self["requestContext"]["identity"].get("principalOrgId") - - @property - def source_ip(self) -> str: - """The source IP address of the TCP connection making the request to API Gateway.""" - return self["requestContext"]["identity"]["sourceIp"] - - @property - def user(self) -> Optional[str]: - """The principal identifier of the user making the request.""" - return self["requestContext"]["identity"].get("user") - - @property - def user_agent(self) -> Optional[str]: - """The User Agent of the API caller.""" - return self["requestContext"]["identity"].get("userAgent") - - @property - def user_arn(self) -> Optional[str]: - """The Amazon Resource Name (ARN) of the effective user identified after authentication.""" - return self["requestContext"]["identity"].get("userArn") +from aws_lambda_powertools.utilities.data_classes.common import ( + BaseProxyEvent, + BaseRequestContext, + BaseRequestContextV2, + DictWrapper, +) class APIGatewayEventAuthorizer(DictWrapper): @@ -89,21 +18,7 @@ def scopes(self) -> Optional[List[str]]: return self["requestContext"]["authorizer"].get("scopes") -class APIGatewayEventRequestContext(DictWrapper): - @property - def account_id(self) -> str: - """The AWS account ID associated with the request.""" - return self["requestContext"]["accountId"] - - @property - def api_id(self) -> str: - """The identifier API Gateway assigns to your API.""" - return self["requestContext"]["apiId"] - - @property - def authorizer(self) -> APIGatewayEventAuthorizer: - return APIGatewayEventAuthorizer(self._data) - +class APIGatewayEventRequestContext(BaseRequestContext): @property def connected_at(self) -> Optional[int]: """The Epoch-formatted connection time. (WebSocket API)""" @@ -114,40 +29,11 @@ def connection_id(self) -> Optional[str]: """A unique ID for the connection that can be used to make a callback to the client. (WebSocket API)""" return self["requestContext"].get("connectionId") - @property - def domain_name(self) -> Optional[str]: - """A domain name""" - return self["requestContext"].get("domainName") - - @property - def domain_prefix(self) -> Optional[str]: - return self["requestContext"].get("domainPrefix") - @property def event_type(self) -> Optional[str]: """The event type: `CONNECT`, `MESSAGE`, or `DISCONNECT`. (WebSocket API)""" return self["requestContext"].get("eventType") - @property - def extended_request_id(self) -> Optional[str]: - """An automatically generated ID for the API call, which contains more useful information - for debugging/troubleshooting.""" - return self["requestContext"].get("extendedRequestId") - - @property - def protocol(self) -> str: - """The request protocol, for example, HTTP/1.1.""" - return self["requestContext"]["protocol"] - - @property - def http_method(self) -> str: - """The HTTP method used. Valid values include: DELETE, GET, HEAD, OPTIONS, PATCH, POST, and PUT.""" - return self["requestContext"]["httpMethod"] - - @property - def identity(self) -> APIGatewayEventIdentity: - return APIGatewayEventIdentity(self._data) - @property def message_direction(self) -> Optional[str]: """Message direction (WebSocket API)""" @@ -159,36 +45,9 @@ def message_id(self) -> Optional[str]: return self["requestContext"].get("messageId") @property - def path(self) -> str: - return self["requestContext"]["path"] - - @property - def stage(self) -> str: - """The deployment stage of the API request""" - return self["requestContext"]["stage"] - - @property - def request_id(self) -> str: - """The ID that API Gateway assigns to the API request.""" - return self["requestContext"]["requestId"] - - @property - def request_time(self) -> Optional[str]: - """The CLF-formatted request time (dd/MMM/yyyy:HH:mm:ss +-hhmm)""" - return self["requestContext"].get("requestTime") - - @property - def request_time_epoch(self) -> int: - """The Epoch-formatted request time.""" - return self["requestContext"]["requestTimeEpoch"] - - @property - def resource_id(self) -> str: - return self["requestContext"]["resourceId"] - - @property - def resource_path(self) -> str: - return self["requestContext"]["resourcePath"] + def operation_name(self) -> Optional[str]: + """The name of the operation being performed""" + return self["requestContext"].get("operationName") @property def route_key(self) -> Optional[str]: @@ -196,9 +55,8 @@ def route_key(self) -> Optional[str]: return self["requestContext"].get("routeKey") @property - def operation_name(self) -> Optional[str]: - """The name of the operation being performed""" - return self["requestContext"].get("operationName") + def authorizer(self) -> APIGatewayEventAuthorizer: + return APIGatewayEventAuthorizer(self._data) class APIGatewayProxyEvent(BaseProxyEvent): @@ -238,31 +96,6 @@ def stage_variables(self) -> Optional[Dict[str, str]]: return self.get("stageVariables") -class RequestContextV2Http(DictWrapper): - @property - def method(self) -> str: - return self["requestContext"]["http"]["method"] - - @property - def path(self) -> str: - return self["requestContext"]["http"]["path"] - - @property - def protocol(self) -> str: - """The request protocol, for example, HTTP/1.1.""" - return self["requestContext"]["http"]["protocol"] - - @property - def source_ip(self) -> str: - """The source IP address of the TCP connection making the request to API Gateway.""" - return self["requestContext"]["http"]["sourceIp"] - - @property - def user_agent(self) -> str: - """The User Agent of the API caller.""" - return self["requestContext"]["http"]["userAgent"] - - class RequestContextV2AuthorizerIam(DictWrapper): @property def access_key(self) -> Optional[str]: @@ -334,60 +167,12 @@ def iam(self) -> Optional[RequestContextV2AuthorizerIam]: return None if iam is None else RequestContextV2AuthorizerIam(iam) -class RequestContextV2(DictWrapper): - @property - def account_id(self) -> str: - """The AWS account ID associated with the request.""" - return self["requestContext"]["accountId"] - - @property - def api_id(self) -> str: - """The identifier API Gateway assigns to your API.""" - return self["requestContext"]["apiId"] - +class RequestContextV2(BaseRequestContextV2): @property def authorizer(self) -> Optional[RequestContextV2Authorizer]: authorizer = self["requestContext"].get("authorizer") return None if authorizer is None else RequestContextV2Authorizer(authorizer) - @property - def domain_name(self) -> str: - """A domain name""" - return self["requestContext"]["domainName"] - - @property - def domain_prefix(self) -> str: - return self["requestContext"]["domainPrefix"] - - @property - def http(self) -> RequestContextV2Http: - return RequestContextV2Http(self._data) - - @property - def request_id(self) -> str: - """The ID that API Gateway assigns to the API request.""" - return self["requestContext"]["requestId"] - - @property - def route_key(self) -> str: - """The selected route key.""" - return self["requestContext"]["routeKey"] - - @property - def stage(self) -> str: - """The deployment stage of the API request""" - return self["requestContext"]["stage"] - - @property - def time(self) -> str: - """The CLF-formatted request time (dd/MMM/yyyy:HH:mm:ss +-hhmm).""" - return self["requestContext"]["time"] - - @property - def time_epoch(self) -> int: - """The Epoch-formatted request time.""" - return self["requestContext"]["timeEpoch"] - class APIGatewayProxyEventV2(BaseProxyEvent): """AWS Lambda proxy V2 event @@ -440,6 +225,9 @@ def stage_variables(self) -> Optional[Dict[str, str]]: @property def path(self) -> str: + stage = self.request_context.stage + if stage != "$default": + return self.raw_path[len("/" + stage) :] return self.raw_path @property diff --git a/aws_lambda_powertools/utilities/data_classes/appsync_authorizer_event.py b/aws_lambda_powertools/utilities/data_classes/appsync_authorizer_event.py new file mode 100644 index 00000000000..b9366871211 --- /dev/null +++ b/aws_lambda_powertools/utilities/data_classes/appsync_authorizer_event.py @@ -0,0 +1,116 @@ +from typing import Any, Dict, List, Optional + +from aws_lambda_powertools.utilities.data_classes.common import DictWrapper + + +class AppSyncAuthorizerEventRequestContext(DictWrapper): + """Request context""" + + @property + def api_id(self) -> str: + """AppSync API ID""" + return self["requestContext"]["apiId"] + + @property + def account_id(self) -> str: + """AWS Account ID""" + return self["requestContext"]["accountId"] + + @property + def request_id(self) -> str: + """Requestt ID""" + return self["requestContext"]["requestId"] + + @property + def query_string(self) -> str: + """GraphQL query string""" + return self["requestContext"]["queryString"] + + @property + def operation_name(self) -> Optional[str]: + """GraphQL operation name, optional""" + return self["requestContext"].get("operationName") + + @property + def variables(self) -> Dict: + """GraphQL variables""" + return self["requestContext"]["variables"] + + +class AppSyncAuthorizerEvent(DictWrapper): + """AppSync lambda authorizer event + + Documentation: + ------------- + - https://aws.amazon.com/blogs/mobile/appsync-lambda-auth/ + - https://docs.aws.amazon.com/appsync/latest/devguide/security-authz.html#aws-lambda-authorization + - https://docs.amplify.aws/lib/graphqlapi/authz/q/platform/js#aws-lambda + """ + + @property + def authorization_token(self) -> str: + """Authorization token""" + return self["authorizationToken"] + + @property + def request_context(self) -> AppSyncAuthorizerEventRequestContext: + """Request context""" + return AppSyncAuthorizerEventRequestContext(self._data) + + +class AppSyncAuthorizerResponse: + """AppSync Lambda authorizer response helper + + Parameters + ---------- + authorize: bool + authorize is a boolean value indicating if the value in authorizationToken + is authorized to make calls to the GraphQL API. If this value is + true, execution of the GraphQL API continues. If this value is false, + an UnauthorizedException is raised + max_age: int, optional + Set the ttlOverride. The number of seconds that the response should be + cached for. If no value is returned, the value from the API (if configured) + or the default of 300 seconds (five minutes) is used. If this is 0, the response + is not cached. + resolver_context: Dict[str, Any], optional + A JSON object visible as `$ctx.identity.resolverContext` in resolver templates + + The resolverContext object only supports key-value pairs. Nested keys are not supported. + + Warning: The total size of this JSON object must not exceed 5MB. + deny_fields: List[str], optional + A list of fields that will be set to `null` regardless of the resolver's return. + + A field is either `TypeName.FieldName`, or an ARN such as + `arn:aws:appsync:us-east-1:111122223333:apis/GraphQLApiId/types/TypeName/fields/FieldName` + + Use the full ARN for correctness when sharing a Lambda function authorizer between APIs. + """ + + def __init__( + self, + authorize: bool = False, + max_age: Optional[int] = None, + resolver_context: Optional[Dict[str, Any]] = None, + deny_fields: Optional[List[str]] = None, + ): + self.authorize = authorize + self.max_age = max_age + self.deny_fields = deny_fields + self.resolver_context = resolver_context + + def asdict(self) -> dict: + """Return the response as a dict""" + response: Dict = {"isAuthorized": self.authorize} + + if self.max_age is not None: + response["ttlOverride"] = self.max_age + + if self.deny_fields: + response["deniedFields"] = self.deny_fields + + if self.resolver_context: + response["resolverContext"] = self.resolver_context + + return response diff --git a/aws_lambda_powertools/utilities/data_classes/common.py b/aws_lambda_powertools/utilities/data_classes/common.py index fbf0502125e..566e1c56259 100644 --- a/aws_lambda_powertools/utilities/data_classes/common.py +++ b/aws_lambda_powertools/utilities/data_classes/common.py @@ -120,3 +120,274 @@ def get_header_value( Header value """ return get_header_value(self.headers, name, default_value, case_sensitive) + + +class RequestContextClientCert(DictWrapper): + @property + def client_cert_pem(self) -> str: + """Client certificate pem""" + return self["clientCertPem"] + + @property + def issuer_dn(self) -> str: + """Issuer Distinguished Name""" + return self["issuerDN"] + + @property + def serial_number(self) -> str: + """Unique serial number for client cert""" + return self["serialNumber"] + + @property + def subject_dn(self) -> str: + """Subject Distinguished Name""" + return self["subjectDN"] + + @property + def validity_not_after(self) -> str: + """Date when the cert is no longer valid + + eg: Aug 5 00:28:21 2120 GMT""" + return self["validity"]["notAfter"] + + @property + def validity_not_before(self) -> str: + """Cert is not valid before this date + + eg: Aug 29 00:28:21 2020 GMT""" + return self["validity"]["notBefore"] + + +class APIGatewayEventIdentity(DictWrapper): + @property + def access_key(self) -> Optional[str]: + return self["requestContext"]["identity"].get("accessKey") + + @property + def account_id(self) -> Optional[str]: + """The AWS account ID associated with the request.""" + return self["requestContext"]["identity"].get("accountId") + + @property + def api_key(self) -> Optional[str]: + """For API methods that require an API key, this variable is the API key associated with the method request. + For methods that don't require an API key, this variable is null.""" + return self["requestContext"]["identity"].get("apiKey") + + @property + def api_key_id(self) -> Optional[str]: + """The API key ID associated with an API request that requires an API key.""" + return self["requestContext"]["identity"].get("apiKeyId") + + @property + def caller(self) -> Optional[str]: + """The principal identifier of the caller making the request.""" + return self["requestContext"]["identity"].get("caller") + + @property + def cognito_authentication_provider(self) -> Optional[str]: + """A comma-separated list of the Amazon Cognito authentication providers used by the caller + making the request. Available only if the request was signed with Amazon Cognito credentials.""" + return self["requestContext"]["identity"].get("cognitoAuthenticationProvider") + + @property + def cognito_authentication_type(self) -> Optional[str]: + """The Amazon Cognito authentication type of the caller making the request. + Available only if the request was signed with Amazon Cognito credentials.""" + return self["requestContext"]["identity"].get("cognitoAuthenticationType") + + @property + def cognito_identity_id(self) -> Optional[str]: + """The Amazon Cognito identity ID of the caller making the request. + Available only if the request was signed with Amazon Cognito credentials.""" + return self["requestContext"]["identity"].get("cognitoIdentityId") + + @property + def cognito_identity_pool_id(self) -> Optional[str]: + """The Amazon Cognito identity pool ID of the caller making the request. + Available only if the request was signed with Amazon Cognito credentials.""" + return self["requestContext"]["identity"].get("cognitoIdentityPoolId") + + @property + def principal_org_id(self) -> Optional[str]: + """The AWS organization ID.""" + return self["requestContext"]["identity"].get("principalOrgId") + + @property + def source_ip(self) -> str: + """The source IP address of the TCP connection making the request to API Gateway.""" + return self["requestContext"]["identity"]["sourceIp"] + + @property + def user(self) -> Optional[str]: + """The principal identifier of the user making the request.""" + return self["requestContext"]["identity"].get("user") + + @property + def user_agent(self) -> Optional[str]: + """The User Agent of the API caller.""" + return self["requestContext"]["identity"].get("userAgent") + + @property + def user_arn(self) -> Optional[str]: + """The Amazon Resource Name (ARN) of the effective user identified after authentication.""" + return self["requestContext"]["identity"].get("userArn") + + @property + def client_cert(self) -> Optional[RequestContextClientCert]: + client_cert = self["requestContext"]["identity"].get("clientCert") + return None if client_cert is None else RequestContextClientCert(client_cert) + + +class BaseRequestContext(DictWrapper): + @property + def account_id(self) -> str: + """The AWS account ID associated with the request.""" + return self["requestContext"]["accountId"] + + @property + def api_id(self) -> str: + """The identifier API Gateway assigns to your API.""" + return self["requestContext"]["apiId"] + + @property + def domain_name(self) -> Optional[str]: + """A domain name""" + return self["requestContext"].get("domainName") + + @property + def domain_prefix(self) -> Optional[str]: + return self["requestContext"].get("domainPrefix") + + @property + def extended_request_id(self) -> Optional[str]: + """An automatically generated ID for the API call, which contains more useful information + for debugging/troubleshooting.""" + return self["requestContext"].get("extendedRequestId") + + @property + def protocol(self) -> str: + """The request protocol, for example, HTTP/1.1.""" + return self["requestContext"]["protocol"] + + @property + def http_method(self) -> str: + """The HTTP method used. Valid values include: DELETE, GET, HEAD, OPTIONS, PATCH, POST, and PUT.""" + return self["requestContext"]["httpMethod"] + + @property + def identity(self) -> APIGatewayEventIdentity: + return APIGatewayEventIdentity(self._data) + + @property + def path(self) -> str: + return self["requestContext"]["path"] + + @property + def stage(self) -> str: + """The deployment stage of the API request""" + return self["requestContext"]["stage"] + + @property + def request_id(self) -> str: + """The ID that API Gateway assigns to the API request.""" + return self["requestContext"]["requestId"] + + @property + def request_time(self) -> Optional[str]: + """The CLF-formatted request time (dd/MMM/yyyy:HH:mm:ss +-hhmm)""" + return self["requestContext"].get("requestTime") + + @property + def request_time_epoch(self) -> int: + """The Epoch-formatted request time.""" + return self["requestContext"]["requestTimeEpoch"] + + @property + def resource_id(self) -> str: + return self["requestContext"]["resourceId"] + + @property + def resource_path(self) -> str: + return self["requestContext"]["resourcePath"] + + +class RequestContextV2Http(DictWrapper): + @property + def method(self) -> str: + return self["requestContext"]["http"]["method"] + + @property + def path(self) -> str: + return self["requestContext"]["http"]["path"] + + @property + def protocol(self) -> str: + """The request protocol, for example, HTTP/1.1.""" + return self["requestContext"]["http"]["protocol"] + + @property + def source_ip(self) -> str: + """The source IP address of the TCP connection making the request to API Gateway.""" + return self["requestContext"]["http"]["sourceIp"] + + @property + def user_agent(self) -> str: + """The User Agent of the API caller.""" + return self["requestContext"]["http"]["userAgent"] + + +class BaseRequestContextV2(DictWrapper): + @property + def account_id(self) -> str: + """The AWS account ID associated with the request.""" + return self["requestContext"]["accountId"] + + @property + def api_id(self) -> str: + """The identifier API Gateway assigns to your API.""" + return self["requestContext"]["apiId"] + + @property + def domain_name(self) -> str: + """A domain name""" + return self["requestContext"]["domainName"] + + @property + def domain_prefix(self) -> str: + return self["requestContext"]["domainPrefix"] + + @property + def http(self) -> RequestContextV2Http: + return RequestContextV2Http(self._data) + + @property + def request_id(self) -> str: + """The ID that API Gateway assigns to the API request.""" + return self["requestContext"]["requestId"] + + @property + def route_key(self) -> str: + """The selected route key.""" + return self["requestContext"]["routeKey"] + + @property + def stage(self) -> str: + """The deployment stage of the API request""" + return self["requestContext"]["stage"] + + @property + def time(self) -> str: + """The CLF-formatted request time (dd/MMM/yyyy:HH:mm:ss +-hhmm).""" + return self["requestContext"]["time"] + + @property + def time_epoch(self) -> int: + """The Epoch-formatted request time.""" + return self["requestContext"]["timeEpoch"] + + @property + def authentication(self) -> Optional[RequestContextClientCert]: + """Optional when using mutual TLS authentication""" + client_cert = self["requestContext"].get("authentication", {}).get("clientCert") + return None if client_cert is None else RequestContextClientCert(client_cert) diff --git a/aws_lambda_powertools/utilities/data_classes/kinesis_stream_event.py b/aws_lambda_powertools/utilities/data_classes/kinesis_stream_event.py index 6af1484f155..ec45bfbd0b2 100644 --- a/aws_lambda_powertools/utilities/data_classes/kinesis_stream_event.py +++ b/aws_lambda_powertools/utilities/data_classes/kinesis_stream_event.py @@ -31,9 +31,13 @@ def sequence_number(self) -> str: """The unique identifier of the record within its shard""" return self["kinesis"]["sequenceNumber"] + def data_as_bytes(self) -> bytes: + """Decode binary encoded data as bytes""" + return base64.b64decode(self.data) + def data_as_text(self) -> str: """Decode binary encoded data as text""" - return base64.b64decode(self.data).decode("utf-8") + return self.data_as_bytes().decode("utf-8") def data_as_json(self) -> dict: """Decode binary encoded data as json""" diff --git a/aws_lambda_powertools/utilities/feature_flags/appconfig.py b/aws_lambda_powertools/utilities/feature_flags/appconfig.py index 30c70b6c590..2e0edc3b9b1 100644 --- a/aws_lambda_powertools/utilities/feature_flags/appconfig.py +++ b/aws_lambda_powertools/utilities/feature_flags/appconfig.py @@ -80,7 +80,7 @@ def get_configuration(self) -> Dict[str, Any]: ) if self.envelope: - config = jmespath_utils.unwrap_event_from_envelope( + config = jmespath_utils.extract_data_from_envelope( data=config, envelope=self.envelope, jmespath_options=self.jmespath_options ) diff --git a/aws_lambda_powertools/utilities/idempotency/__init__.py b/aws_lambda_powertools/utilities/idempotency/__init__.py index b46d0855a93..4461453a8be 100644 --- a/aws_lambda_powertools/utilities/idempotency/__init__.py +++ b/aws_lambda_powertools/utilities/idempotency/__init__.py @@ -5,6 +5,6 @@ from aws_lambda_powertools.utilities.idempotency.persistence.base import BasePersistenceLayer from aws_lambda_powertools.utilities.idempotency.persistence.dynamodb import DynamoDBPersistenceLayer -from .idempotency import IdempotencyConfig, idempotent +from .idempotency import IdempotencyConfig, idempotent, idempotent_function -__all__ = ("DynamoDBPersistenceLayer", "BasePersistenceLayer", "idempotent", "IdempotencyConfig") +__all__ = ("DynamoDBPersistenceLayer", "BasePersistenceLayer", "idempotent", "idempotent_function", "IdempotencyConfig") diff --git a/aws_lambda_powertools/utilities/idempotency/base.py b/aws_lambda_powertools/utilities/idempotency/base.py new file mode 100644 index 00000000000..4b82c923a70 --- /dev/null +++ b/aws_lambda_powertools/utilities/idempotency/base.py @@ -0,0 +1,181 @@ +import logging +from typing import Any, Callable, Dict, Optional, Tuple + +from aws_lambda_powertools.utilities.idempotency.config import IdempotencyConfig +from aws_lambda_powertools.utilities.idempotency.exceptions import ( + IdempotencyAlreadyInProgressError, + IdempotencyInconsistentStateError, + IdempotencyItemAlreadyExistsError, + IdempotencyItemNotFoundError, + IdempotencyKeyError, + IdempotencyPersistenceLayerError, + IdempotencyValidationError, +) +from aws_lambda_powertools.utilities.idempotency.persistence.base import ( + STATUS_CONSTANTS, + BasePersistenceLayer, + DataRecord, +) + +MAX_RETRIES = 2 +logger = logging.getLogger(__name__) + + +class IdempotencyHandler: + """ + Base class to orchestrate calls to persistence layer. + """ + + def __init__( + self, + function: Callable, + function_payload: Any, + config: IdempotencyConfig, + persistence_store: BasePersistenceLayer, + function_args: Optional[Tuple] = None, + function_kwargs: Optional[Dict] = None, + ): + """ + Initialize the IdempotencyHandler + + Parameters + ---------- + function_payload: Any + JSON Serializable payload to be hashed + config: IdempotencyConfig + Idempotency Configuration + persistence_store : BasePersistenceLayer + Instance of persistence layer to store idempotency records + function_args: Optional[Tuple] + Function arguments + function_kwargs: Optional[Dict] + Function keyword arguments + """ + self.function = function + self.data = function_payload + self.fn_args = function_args + self.fn_kwargs = function_kwargs + + persistence_store.configure(config) + self.persistence_store = persistence_store + + def handle(self) -> Any: + """ + Main entry point for handling idempotent execution of a function. + + Returns + ------- + Any + Function response + + """ + # IdempotencyInconsistentStateError can happen under rare but expected cases + # when persistent state changes in the small time between put & get requests. + # In most cases we can retry successfully on this exception. + for i in range(MAX_RETRIES + 1): # pragma: no cover + try: + return self._process_idempotency() + except IdempotencyInconsistentStateError: + if i == MAX_RETRIES: + raise # Bubble up when exceeded max tries + + def _process_idempotency(self): + try: + # We call save_inprogress first as an optimization for the most common case where no idempotent record + # already exists. If it succeeds, there's no need to call get_record. + self.persistence_store.save_inprogress(data=self.data) + except IdempotencyKeyError: + raise + except IdempotencyItemAlreadyExistsError: + # Now we know the item already exists, we can retrieve it + record = self._get_idempotency_record() + return self._handle_for_status(record) + except Exception as exc: + raise IdempotencyPersistenceLayerError("Failed to save in progress record to idempotency store") from exc + + return self._get_function_response() + + def _get_idempotency_record(self) -> DataRecord: + """ + Retrieve the idempotency record from the persistence layer. + + Raises + ---------- + IdempotencyInconsistentStateError + + """ + try: + data_record = self.persistence_store.get_record(data=self.data) + except IdempotencyItemNotFoundError: + # This code path will only be triggered if the record is removed between save_inprogress and get_record. + logger.debug( + f"An existing idempotency record was deleted before we could fetch it. Proceeding with {self.function}" + ) + raise IdempotencyInconsistentStateError("save_inprogress and get_record return inconsistent results.") + + # Allow this exception to bubble up + except IdempotencyValidationError: + raise + + # Wrap remaining unhandled exceptions with IdempotencyPersistenceLayerError to ease exception handling for + # clients + except Exception as exc: + raise IdempotencyPersistenceLayerError("Failed to get record from idempotency store") from exc + + return data_record + + def _handle_for_status(self, data_record: DataRecord) -> Optional[Dict[Any, Any]]: + """ + Take appropriate action based on data_record's status + + Parameters + ---------- + data_record: DataRecord + + Returns + ------- + Optional[Dict[Any, Any] + Function's response previously used for this idempotency key, if it has successfully executed already. + + Raises + ------ + AlreadyInProgressError + A function execution is already in progress + IdempotencyInconsistentStateError + The persistence store reports inconsistent states across different requests. Retryable. + """ + # This code path will only be triggered if the record becomes expired between the save_inprogress call and here + if data_record.status == STATUS_CONSTANTS["EXPIRED"]: + raise IdempotencyInconsistentStateError("save_inprogress and get_record return inconsistent results.") + + if data_record.status == STATUS_CONSTANTS["INPROGRESS"]: + raise IdempotencyAlreadyInProgressError( + f"Execution already in progress with idempotency key: " + f"{self.persistence_store.event_key_jmespath}={data_record.idempotency_key}" + ) + + return data_record.response_json_as_dict() + + def _get_function_response(self): + try: + response = self.function(*self.fn_args, **self.fn_kwargs) + except Exception as handler_exception: + # We need these nested blocks to preserve function's exception in case the persistence store operation + # also raises an exception + try: + self.persistence_store.delete_record(data=self.data, exception=handler_exception) + except Exception as delete_exception: + raise IdempotencyPersistenceLayerError( + "Failed to delete record from idempotency store" + ) from delete_exception + raise + + else: + try: + self.persistence_store.save_success(data=self.data, result=response) + except Exception as save_exception: + raise IdempotencyPersistenceLayerError( + "Failed to update record state to success in idempotency store" + ) from save_exception + + return response diff --git a/aws_lambda_powertools/utilities/idempotency/idempotency.py b/aws_lambda_powertools/utilities/idempotency/idempotency.py index fc1d4d47d55..06c9a578aa2 100644 --- a/aws_lambda_powertools/utilities/idempotency/idempotency.py +++ b/aws_lambda_powertools/utilities/idempotency/idempotency.py @@ -1,25 +1,15 @@ """ Primary interface for idempotent Lambda functions utility """ +import functools import logging -from typing import Any, Callable, Dict, Optional +from typing import Any, Callable, Dict, Optional, cast from aws_lambda_powertools.middleware_factory import lambda_handler_decorator +from aws_lambda_powertools.shared.types import AnyCallableT +from aws_lambda_powertools.utilities.idempotency.base import IdempotencyHandler from aws_lambda_powertools.utilities.idempotency.config import IdempotencyConfig -from aws_lambda_powertools.utilities.idempotency.exceptions import ( - IdempotencyAlreadyInProgressError, - IdempotencyInconsistentStateError, - IdempotencyItemAlreadyExistsError, - IdempotencyItemNotFoundError, - IdempotencyKeyError, - IdempotencyPersistenceLayerError, - IdempotencyValidationError, -) -from aws_lambda_powertools.utilities.idempotency.persistence.base import ( - STATUS_CONSTANTS, - BasePersistenceLayer, - DataRecord, -) +from aws_lambda_powertools.utilities.idempotency.persistence.base import BasePersistenceLayer from aws_lambda_powertools.utilities.typing import LambdaContext logger = logging.getLogger(__name__) @@ -32,9 +22,10 @@ def idempotent( context: LambdaContext, persistence_store: BasePersistenceLayer, config: Optional[IdempotencyConfig] = None, + **kwargs, ) -> Any: """ - Middleware to handle idempotency + Decorator to handle idempotency Parameters ---------- @@ -66,174 +57,88 @@ def idempotent( """ config = config or IdempotencyConfig() + args = event, context idempotency_handler = IdempotencyHandler( - lambda_handler=handler, event=event, context=context, persistence_store=persistence_store, config=config + function=handler, + function_payload=event, + config=config, + persistence_store=persistence_store, + function_args=args, + function_kwargs=kwargs, ) - # IdempotencyInconsistentStateError can happen under rare but expected cases when persistent state changes in the - # small time between put & get requests. In most cases we can retry successfully on this exception. - # Maintenance: Allow customers to specify number of retries - max_handler_retries = 2 - for i in range(max_handler_retries + 1): - try: - return idempotency_handler.handle() - except IdempotencyInconsistentStateError: - if i == max_handler_retries: - # Allow the exception to bubble up after max retries exceeded - raise + return idempotency_handler.handle() -class IdempotencyHandler: +def idempotent_function( + function: Optional[AnyCallableT] = None, + *, + data_keyword_argument: str, + persistence_store: BasePersistenceLayer, + config: Optional[IdempotencyConfig] = None, +) -> Any: """ - Class to orchestrate calls to persistence layer. + Decorator to handle idempotency of any function + + Parameters + ---------- + function: Callable + Function to be decorated + data_keyword_argument: str + Keyword parameter name in function's signature that we should hash as idempotency key, e.g. "order" + persistence_store: BasePersistenceLayer + Instance of BasePersistenceLayer to store data + config: IdempotencyConfig + Configuration + + Examples + -------- + **Processes an order in an idempotent manner** + + from aws_lambda_powertools.utilities.idempotency import ( + idempotent_function, DynamoDBPersistenceLayer, IdempotencyConfig + ) + + idem_config=IdempotencyConfig(event_key_jmespath="order_id") + persistence_layer = DynamoDBPersistenceLayer(table_name="idempotency_store") + + @idempotent_function(data_keyword_argument="order", config=idem_config, persistence_store=persistence_layer) + def process_order(customer_id: str, order: dict, **kwargs): + return {"StatusCode": 200} """ - def __init__( - self, - lambda_handler: Callable[[Any, LambdaContext], Any], - event: Dict[str, Any], - context: LambdaContext, - config: IdempotencyConfig, - persistence_store: BasePersistenceLayer, - ): - """ - Initialize the IdempotencyHandler - - Parameters - ---------- - lambda_handler : Callable[[Any, LambdaContext], Any] - Lambda function handler - event : Dict[str, Any] - Event payload lambda handler will be called with - context : LambdaContext - Context object which will be passed to lambda handler - persistence_store : BasePersistenceLayer - Instance of persistence layer to store idempotency records - """ - persistence_store.configure(config) - self.persistence_store = persistence_store - self.context = context - self.event = event - self.lambda_handler = lambda_handler - - def handle(self) -> Any: - """ - Main entry point for handling idempotent execution of lambda handler. - - Returns - ------- - Any - lambda handler response - - """ - try: - # We call save_inprogress first as an optimization for the most common case where no idempotent record - # already exists. If it succeeds, there's no need to call get_record. - self.persistence_store.save_inprogress(event=self.event, context=self.context) - except IdempotencyKeyError: - raise - except IdempotencyItemAlreadyExistsError: - # Now we know the item already exists, we can retrieve it - record = self._get_idempotency_record() - return self._handle_for_status(record) - except Exception as exc: - raise IdempotencyPersistenceLayerError("Failed to save in progress record to idempotency store") from exc - - return self._call_lambda_handler() - - def _get_idempotency_record(self) -> DataRecord: - """ - Retrieve the idempotency record from the persistence layer. - - Raises - ---------- - IdempotencyInconsistentStateError - - """ - try: - event_record = self.persistence_store.get_record(event=self.event, context=self.context) - except IdempotencyItemNotFoundError: - # This code path will only be triggered if the record is removed between save_inprogress and get_record. - logger.debug( - "An existing idempotency record was deleted before we could retrieve it. Proceeding with lambda " - "handler" - ) - raise IdempotencyInconsistentStateError("save_inprogress and get_record return inconsistent results.") - - # Allow this exception to bubble up - except IdempotencyValidationError: - raise - - # Wrap remaining unhandled exceptions with IdempotencyPersistenceLayerError to ease exception handling for - # clients - except Exception as exc: - raise IdempotencyPersistenceLayerError("Failed to get record from idempotency store") from exc - - return event_record - - def _handle_for_status(self, event_record: DataRecord) -> Optional[Dict[Any, Any]]: - """ - Take appropriate action based on event_record's status - - Parameters - ---------- - event_record: DataRecord - - Returns - ------- - Optional[Dict[Any, Any] - Lambda response previously used for this idempotency key, if it has successfully executed already. - - Raises - ------ - AlreadyInProgressError - A lambda execution is already in progress - IdempotencyInconsistentStateError - The persistence store reports inconsistent states across different requests. Retryable. - """ - # This code path will only be triggered if the record becomes expired between the save_inprogress call and here - if event_record.status == STATUS_CONSTANTS["EXPIRED"]: - raise IdempotencyInconsistentStateError("save_inprogress and get_record return inconsistent results.") - - if event_record.status == STATUS_CONSTANTS["INPROGRESS"]: - raise IdempotencyAlreadyInProgressError( - f"Execution already in progress with idempotency key: " - f"{self.persistence_store.event_key_jmespath}={event_record.idempotency_key}" + if function is None: + return cast( + AnyCallableT, + functools.partial( + idempotent_function, + data_keyword_argument=data_keyword_argument, + persistence_store=persistence_store, + config=config, + ), + ) + + config = config or IdempotencyConfig() + + @functools.wraps(function) + def decorate(*args, **kwargs): + payload = kwargs.get(data_keyword_argument) + + if payload is None: + raise RuntimeError( + f"Unable to extract '{data_keyword_argument}' from keyword arguments." + f" Ensure this exists in your function's signature as well as the caller used it as a keyword argument" ) - return event_record.response_json_as_dict() - - def _call_lambda_handler(self) -> Any: - """ - Call the lambda handler function and update the persistence store appropriate depending on the output - - Returns - ------- - Any - lambda handler response - - """ - try: - handler_response = self.lambda_handler(self.event, self.context) - except Exception as handler_exception: - # We need these nested blocks to preserve lambda handler exception in case the persistence store operation - # also raises an exception - try: - self.persistence_store.delete_record( - event=self.event, context=self.context, exception=handler_exception - ) - except Exception as delete_exception: - raise IdempotencyPersistenceLayerError( - "Failed to delete record from idempotency store" - ) from delete_exception - raise - - else: - try: - self.persistence_store.save_success(event=self.event, context=self.context, result=handler_response) - except Exception as save_exception: - raise IdempotencyPersistenceLayerError( - "Failed to update record state to success in idempotency store" - ) from save_exception - - return handler_response + idempotency_handler = IdempotencyHandler( + function=function, + function_payload=payload, + config=config, + persistence_store=persistence_store, + function_args=args, + function_kwargs=kwargs, + ) + + return idempotency_handler.handle() + + return cast(AnyCallableT, decorate) diff --git a/aws_lambda_powertools/utilities/idempotency/persistence/base.py b/aws_lambda_powertools/utilities/idempotency/persistence/base.py index 0388adfbf55..2f5dd512ac6 100644 --- a/aws_lambda_powertools/utilities/idempotency/persistence/base.py +++ b/aws_lambda_powertools/utilities/idempotency/persistence/base.py @@ -6,6 +6,7 @@ import hashlib import json import logging +import os import warnings from abc import ABC, abstractmethod from types import MappingProxyType @@ -13,6 +14,7 @@ import jmespath +from aws_lambda_powertools.shared import constants from aws_lambda_powertools.shared.cache_dict import LRUDict from aws_lambda_powertools.shared.jmespath_utils import PowertoolsFunctions from aws_lambda_powertools.shared.json_encoder import Encoder @@ -23,7 +25,6 @@ IdempotencyKeyError, IdempotencyValidationError, ) -from aws_lambda_powertools.utilities.typing import LambdaContext logger = logging.getLogger(__name__) @@ -153,16 +154,14 @@ def configure(self, config: IdempotencyConfig) -> None: self._cache = LRUDict(max_items=config.local_cache_max_items) self.hash_function = getattr(hashlib, config.hash_function) - def _get_hashed_idempotency_key(self, event: Dict[str, Any], context: LambdaContext) -> str: + def _get_hashed_idempotency_key(self, data: Dict[str, Any]) -> str: """ - Extract data from lambda event using event key jmespath, and return a hashed representation + Extract idempotency key and return a hashed representation Parameters ---------- - event: Dict[str, Any] - Lambda event - context: LambdaContext - Lambda context + data: Dict[str, Any] + Incoming data Returns ------- @@ -170,18 +169,17 @@ def _get_hashed_idempotency_key(self, event: Dict[str, Any], context: LambdaCont Hashed representation of the data extracted by the jmespath expression """ - data = event - if self.event_key_jmespath: - data = self.event_key_compiled_jmespath.search(event, options=jmespath.Options(**self.jmespath_options)) + data = self.event_key_compiled_jmespath.search(data, options=jmespath.Options(**self.jmespath_options)) - if self.is_missing_idempotency_key(data): + if self.is_missing_idempotency_key(data=data): if self.raise_on_no_idempotency_key: raise IdempotencyKeyError("No data found to create a hashed idempotency_key") warnings.warn(f"No value found for idempotency_key. jmespath: {self.event_key_jmespath}") - generated_hash = self._generate_hash(data) - return f"{context.function_name}#{generated_hash}" + generated_hash = self._generate_hash(data=data) + function_name = os.getenv(constants.LAMBDA_FUNCTION_NAME_ENV, "test-func") + return f"{function_name}#{generated_hash}" @staticmethod def is_missing_idempotency_key(data) -> bool: @@ -189,14 +187,14 @@ def is_missing_idempotency_key(data) -> bool: return all(x is None for x in data) return not data - def _get_hashed_payload(self, lambda_event: Dict[str, Any]) -> str: + def _get_hashed_payload(self, data: Dict[str, Any]) -> str: """ - Extract data from lambda event using validation key jmespath, and return a hashed representation + Extract payload using validation key jmespath and return a hashed representation Parameters ---------- - lambda_event: Dict[str, Any] - Lambda event + data: Dict[str, Any] + Payload Returns ------- @@ -206,8 +204,8 @@ def _get_hashed_payload(self, lambda_event: Dict[str, Any]) -> str: """ if not self.payload_validation_enabled: return "" - data = self.validation_key_jmespath.search(lambda_event) - return self._generate_hash(data) + data = self.validation_key_jmespath.search(data) + return self._generate_hash(data=data) def _generate_hash(self, data: Any) -> str: """ @@ -228,26 +226,26 @@ def _generate_hash(self, data: Any) -> str: hashed_data = self.hash_function(json.dumps(data, cls=Encoder).encode()) return hashed_data.hexdigest() - def _validate_payload(self, lambda_event: Dict[str, Any], data_record: DataRecord) -> None: + def _validate_payload(self, data: Dict[str, Any], data_record: DataRecord) -> None: """ - Validate that the hashed payload matches in the lambda event and stored data record + Validate that the hashed payload matches data provided and stored data record Parameters ---------- - lambda_event: Dict[str, Any] - Lambda event + data: Dict[str, Any] + Payload data_record: DataRecord DataRecord instance Raises ---------- IdempotencyValidationError - Event payload doesn't match the stored record for the given idempotency key + Payload doesn't match the stored record for the given idempotency key """ if self.payload_validation_enabled: - lambda_payload_hash = self._get_hashed_payload(lambda_event) - if data_record.payload_hash != lambda_payload_hash: + data_hash = self._get_hashed_payload(data=data) + if data_record.payload_hash != data_hash: raise IdempotencyValidationError("Payload does not match stored record for this event key") def _get_expiry_timestamp(self) -> int: @@ -288,12 +286,12 @@ def _save_to_cache(self, data_record: DataRecord): def _retrieve_from_cache(self, idempotency_key: str): if not self.use_local_cache: return - cached_record = self._cache.get(idempotency_key) + cached_record = self._cache.get(key=idempotency_key) if cached_record: if not cached_record.is_expired: return cached_record logger.debug(f"Removing expired local cache record for idempotency key: {idempotency_key}") - self._delete_from_cache(idempotency_key) + self._delete_from_cache(idempotency_key=idempotency_key) def _delete_from_cache(self, idempotency_key: str): if not self.use_local_cache: @@ -301,52 +299,48 @@ def _delete_from_cache(self, idempotency_key: str): if idempotency_key in self._cache: del self._cache[idempotency_key] - def save_success(self, event: Dict[str, Any], context: LambdaContext, result: dict) -> None: + def save_success(self, data: Dict[str, Any], result: dict) -> None: """ Save record of function's execution completing successfully Parameters ---------- - event: Dict[str, Any] - Lambda event - context: LambdaContext - Lambda context + data: Dict[str, Any] + Payload result: dict - The response from lambda handler + The response from function """ response_data = json.dumps(result, cls=Encoder) data_record = DataRecord( - idempotency_key=self._get_hashed_idempotency_key(event, context), + idempotency_key=self._get_hashed_idempotency_key(data=data), status=STATUS_CONSTANTS["COMPLETED"], expiry_timestamp=self._get_expiry_timestamp(), response_data=response_data, - payload_hash=self._get_hashed_payload(event), + payload_hash=self._get_hashed_payload(data=data), ) logger.debug( - f"Lambda successfully executed. Saving record to persistence store with " + f"Function successfully executed. Saving record to persistence store with " f"idempotency key: {data_record.idempotency_key}" ) self._update_record(data_record=data_record) - self._save_to_cache(data_record) + self._save_to_cache(data_record=data_record) - def save_inprogress(self, event: Dict[str, Any], context: LambdaContext) -> None: + def save_inprogress(self, data: Dict[str, Any]) -> None: """ Save record of function's execution being in progress Parameters ---------- - event: Dict[str, Any] - Lambda event - context: LambdaContext - Lambda context + data: Dict[str, Any] + Payload """ data_record = DataRecord( - idempotency_key=self._get_hashed_idempotency_key(event, context), + idempotency_key=self._get_hashed_idempotency_key(data=data), status=STATUS_CONSTANTS["INPROGRESS"], expiry_timestamp=self._get_expiry_timestamp(), - payload_hash=self._get_hashed_payload(event), + payload_hash=self._get_hashed_payload(data=data), ) logger.debug(f"Saving in progress record for idempotency key: {data_record.idempotency_key}") @@ -354,42 +348,37 @@ def save_inprogress(self, event: Dict[str, Any], context: LambdaContext) -> None if self._retrieve_from_cache(idempotency_key=data_record.idempotency_key): raise IdempotencyItemAlreadyExistsError - self._put_record(data_record) + self._put_record(data_record=data_record) - def delete_record(self, event: Dict[str, Any], context: LambdaContext, exception: Exception): + def delete_record(self, data: Dict[str, Any], exception: Exception): """ Delete record from the persistence store Parameters ---------- - event: Dict[str, Any] - Lambda event - context: LambdaContext - Lambda context + data: Dict[str, Any] + Payload exception - The exception raised by the lambda handler + The exception raised by the function """ - data_record = DataRecord(idempotency_key=self._get_hashed_idempotency_key(event, context)) + data_record = DataRecord(idempotency_key=self._get_hashed_idempotency_key(data=data)) logger.debug( - f"Lambda raised an exception ({type(exception).__name__}). Clearing in progress record in persistence " + f"Function raised an exception ({type(exception).__name__}). Clearing in progress record in persistence " f"store for idempotency key: {data_record.idempotency_key}" ) - self._delete_record(data_record) + self._delete_record(data_record=data_record) - self._delete_from_cache(data_record.idempotency_key) + self._delete_from_cache(idempotency_key=data_record.idempotency_key) - def get_record(self, event: Dict[str, Any], context: LambdaContext) -> DataRecord: + def get_record(self, data: Dict[str, Any]) -> DataRecord: """ - Calculate idempotency key for lambda_event, then retrieve item from persistence store using idempotency key - and return it as a DataRecord instance.and return it as a DataRecord instance. + Retrieve idempotency key for data provided, fetch from persistence store, and convert to DataRecord. Parameters ---------- - event: Dict[str, Any] - Lambda event - context: LambdaContext - Lambda context + data: Dict[str, Any] + Payload Returns ------- @@ -401,22 +390,22 @@ def get_record(self, event: Dict[str, Any], context: LambdaContext) -> DataRecor IdempotencyItemNotFoundError Exception raised if no record exists in persistence store with the idempotency key IdempotencyValidationError - Event payload doesn't match the stored record for the given idempotency key + Payload doesn't match the stored record for the given idempotency key """ - idempotency_key = self._get_hashed_idempotency_key(event, context) + idempotency_key = self._get_hashed_idempotency_key(data=data) cached_record = self._retrieve_from_cache(idempotency_key=idempotency_key) if cached_record: logger.debug(f"Idempotency record found in cache with idempotency key: {idempotency_key}") - self._validate_payload(event, cached_record) + self._validate_payload(data=data, data_record=cached_record) return cached_record - record = self._get_record(idempotency_key) + record = self._get_record(idempotency_key=idempotency_key) self._save_to_cache(data_record=record) - self._validate_payload(event, record) + self._validate_payload(data=data, data_record=record) return record @abstractmethod diff --git a/aws_lambda_powertools/utilities/parser/models/apigw.py b/aws_lambda_powertools/utilities/parser/models/apigw.py index ed975e88e81..44ddda6e4f1 100644 --- a/aws_lambda_powertools/utilities/parser/models/apigw.py +++ b/aws_lambda_powertools/utilities/parser/models/apigw.py @@ -68,7 +68,7 @@ class APIGatewayEventRequestContext(BaseModel): routeKey: Optional[str] operationName: Optional[str] - @root_validator + @root_validator(allow_reuse=True) def check_message_id(cls, values): message_id, event_type = values.get("messageId"), values.get("eventType") if message_id is not None and event_type != "MESSAGE": diff --git a/aws_lambda_powertools/utilities/parser/models/cloudwatch.py b/aws_lambda_powertools/utilities/parser/models/cloudwatch.py index 26eeef5b56f..a0fd3e37239 100644 --- a/aws_lambda_powertools/utilities/parser/models/cloudwatch.py +++ b/aws_lambda_powertools/utilities/parser/models/cloudwatch.py @@ -28,7 +28,7 @@ class CloudWatchLogsDecode(BaseModel): class CloudWatchLogsData(BaseModel): decoded_data: CloudWatchLogsDecode = Field(None, alias="data") - @validator("decoded_data", pre=True) + @validator("decoded_data", pre=True, allow_reuse=True) def prepare_data(cls, value): try: logger.debug("Decoding base64 cloudwatch log data before parsing") diff --git a/aws_lambda_powertools/utilities/parser/models/kinesis.py b/aws_lambda_powertools/utilities/parser/models/kinesis.py index fa84674727a..8979d3f102f 100644 --- a/aws_lambda_powertools/utilities/parser/models/kinesis.py +++ b/aws_lambda_powertools/utilities/parser/models/kinesis.py @@ -18,7 +18,7 @@ class KinesisDataStreamRecordPayload(BaseModel): data: bytes # base64 encoded str is parsed into bytes approximateArrivalTimestamp: float - @validator("data", pre=True) + @validator("data", pre=True, allow_reuse=True) def data_base64_decode(cls, value): try: logger.debug("Decoding base64 Kinesis data record before parsing") diff --git a/aws_lambda_powertools/utilities/parser/models/sns.py b/aws_lambda_powertools/utilities/parser/models/sns.py index 76bd82531f4..856757c5464 100644 --- a/aws_lambda_powertools/utilities/parser/models/sns.py +++ b/aws_lambda_powertools/utilities/parser/models/sns.py @@ -25,7 +25,7 @@ class SnsNotificationModel(BaseModel): Timestamp: datetime SignatureVersion: str - @root_validator(pre=True) + @root_validator(pre=True, allow_reuse=True) def check_sqs_protocol(cls, values): sqs_rewritten_keys = ("UnsubscribeURL", "SigningCertURL") if any(key in sqs_rewritten_keys for key in values): diff --git a/aws_lambda_powertools/utilities/validation/exceptions.py b/aws_lambda_powertools/utilities/validation/exceptions.py index 7dbe3f786e4..d4aaa500ec7 100644 --- a/aws_lambda_powertools/utilities/validation/exceptions.py +++ b/aws_lambda_powertools/utilities/validation/exceptions.py @@ -1,3 +1,6 @@ +from ...exceptions import InvalidEnvelopeExpressionError + + class SchemaValidationError(Exception): """When serialization fail schema validation""" @@ -6,5 +9,4 @@ class InvalidSchemaFormatError(Exception): """When JSON Schema is in invalid format""" -class InvalidEnvelopeExpressionError(Exception): - """When JMESPath fails to parse expression""" +__all__ = ["SchemaValidationError", "InvalidSchemaFormatError", "InvalidEnvelopeExpressionError"] diff --git a/aws_lambda_powertools/utilities/validation/validator.py b/aws_lambda_powertools/utilities/validation/validator.py index 02a685a1565..d9ce35fe41b 100644 --- a/aws_lambda_powertools/utilities/validation/validator.py +++ b/aws_lambda_powertools/utilities/validation/validator.py @@ -117,7 +117,7 @@ def handler(event, context): When JMESPath expression to unwrap event is invalid """ if envelope: - event = jmespath_utils.unwrap_event_from_envelope( + event = jmespath_utils.extract_data_from_envelope( data=event, envelope=envelope, jmespath_options=jmespath_options ) @@ -219,7 +219,7 @@ def handler(event, context): When JMESPath expression to unwrap event is invalid """ if envelope: - event = jmespath_utils.unwrap_event_from_envelope( + event = jmespath_utils.extract_data_from_envelope( data=event, envelope=envelope, jmespath_options=jmespath_options ) diff --git a/docs/core/event_handler/api_gateway.md b/docs/core/event_handler/api_gateway.md index a87daa3299a..8e24ba9f5f3 100644 --- a/docs/core/event_handler/api_gateway.md +++ b/docs/core/event_handler/api_gateway.md @@ -23,65 +23,65 @@ This is the sample infrastructure for API Gateway we are using for the examples === "template.yml" - ```yaml - AWSTemplateFormatVersion: '2010-09-09' - Transform: AWS::Serverless-2016-10-31 - Description: Hello world event handler API Gateway - - Globals: - Api: - TracingEnabled: true - Cors: # see CORS section - AllowOrigin: "'https://example.com'" - AllowHeaders: "'Content-Type,Authorization,X-Amz-Date'" - MaxAge: "'300'" - BinaryMediaTypes: # see Binary responses section - - '*~1*' # converts to */* for any binary type - Function: + ```yaml + AWSTemplateFormatVersion: '2010-09-09' + Transform: AWS::Serverless-2016-10-31 + Description: Hello world event handler API Gateway + + Globals: + Api: + TracingEnabled: true + Cors: # see CORS section + AllowOrigin: "'https://example.com'" + AllowHeaders: "'Content-Type,Authorization,X-Amz-Date'" + MaxAge: "'300'" + BinaryMediaTypes: # see Binary responses section + - '*~1*' # converts to */* for any binary type + Function: Timeout: 5 Runtime: python3.8 Tracing: Active - Environment: + Environment: Variables: - LOG_LEVEL: INFO - POWERTOOLS_LOGGER_SAMPLE_RATE: 0.1 - POWERTOOLS_LOGGER_LOG_EVENT: true - POWERTOOLS_METRICS_NAMESPACE: MyServerlessApplication - POWERTOOLS_SERVICE_NAME: hello - - Resources: - HelloWorldFunction: + LOG_LEVEL: INFO + POWERTOOLS_LOGGER_SAMPLE_RATE: 0.1 + POWERTOOLS_LOGGER_LOG_EVENT: true + POWERTOOLS_METRICS_NAMESPACE: MyServerlessApplication + POWERTOOLS_SERVICE_NAME: hello + + Resources: + HelloWorldFunction: Type: AWS::Serverless::Function Properties: - Handler: app.lambda_handler - CodeUri: hello_world - Description: Hello World function - Events: - HelloUniverse: - Type: Api - Properties: - Path: /hello - Method: GET - HelloYou: - Type: Api - Properties: - Path: /hello/{name} # see Dynamic routes section - Method: GET - CustomMessage: - Type: Api - Properties: - Path: /{message}/{name} # see Dynamic routes section - Method: GET - - Outputs: - HelloWorldApigwURL: + Handler: app.lambda_handler + CodeUri: hello_world + Description: Hello World function + Events: + HelloUniverse: + Type: Api + Properties: + Path: /hello + Method: GET + HelloYou: + Type: Api + Properties: + Path: /hello/{name} # see Dynamic routes section + Method: GET + CustomMessage: + Type: Api + Properties: + Path: /{message}/{name} # see Dynamic routes section + Method: GET + + Outputs: + HelloWorldApigwURL: Description: "API Gateway endpoint URL for Prod environment for Hello World Function" Value: !Sub "https://${ServerlessRestApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/hello" HelloWorldFunction: Description: "Hello World Lambda Function ARN" Value: !GetAtt HelloWorldFunction.Arn - ``` + ``` ### API Gateway decorator @@ -93,107 +93,107 @@ Here's an example where we have two separate functions to resolve two paths: `/h === "app.py" - ```python hl_lines="3 7 9 12 18" - from aws_lambda_powertools import Logger, Tracer - from aws_lambda_powertools.logging import correlation_paths - from aws_lambda_powertools.event_handler.api_gateway import ApiGatewayResolver - - tracer = Tracer() - logger = Logger() - app = ApiGatewayResolver() # by default API Gateway REST API (v1) - - @app.get("/hello") - @tracer.capture_method - def get_hello_universe(): - return {"message": "hello universe"} - - # You can continue to use other utilities just as before - @logger.inject_lambda_context(correlation_id_path=correlation_paths.API_GATEWAY_REST) - @tracer.capture_lambda_handler - def lambda_handler(event, context): - return app.resolve(event, context) - ``` + ```python hl_lines="3 7 9 12 18" + from aws_lambda_powertools import Logger, Tracer + from aws_lambda_powertools.logging import correlation_paths + from aws_lambda_powertools.event_handler.api_gateway import ApiGatewayResolver + + tracer = Tracer() + logger = Logger() + app = ApiGatewayResolver() # by default API Gateway REST API (v1) + + @app.get("/hello") + @tracer.capture_method + def get_hello_universe(): + return {"message": "hello universe"} + + # You can continue to use other utilities just as before + @logger.inject_lambda_context(correlation_id_path=correlation_paths.API_GATEWAY_REST) + @tracer.capture_lambda_handler + def lambda_handler(event, context): + return app.resolve(event, context) + ``` === "hello_event.json" - This utility uses `path` and `httpMethod` to route to the right function. This helps make unit tests and local invocation easier too. - - ```json hl_lines="4-5" - { - "body": "hello", - "resource": "/hello", - "path": "/hello", - "httpMethod": "GET", - "isBase64Encoded": false, - "queryStringParameters": { - "foo": "bar" - }, - "multiValueQueryStringParameters": {}, - "pathParameters": { - "hello": "/hello" - }, - "stageVariables": {}, - "headers": { - "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8", - "Accept-Encoding": "gzip, deflate, sdch", - "Accept-Language": "en-US,en;q=0.8", - "Cache-Control": "max-age=0", - "CloudFront-Forwarded-Proto": "https", - "CloudFront-Is-Desktop-Viewer": "true", - "CloudFront-Is-Mobile-Viewer": "false", - "CloudFront-Is-SmartTV-Viewer": "false", - "CloudFront-Is-Tablet-Viewer": "false", - "CloudFront-Viewer-Country": "US", - "Host": "1234567890.execute-api.us-east-1.amazonaws.com", - "Upgrade-Insecure-Requests": "1", - "User-Agent": "Custom User Agent String", - "Via": "1.1 08f323deadbeefa7af34d5feb414ce27.cloudfront.net (CloudFront)", - "X-Amz-Cf-Id": "cDehVQoZnx43VYQb9j2-nvCh-9z396Uhbp027Y2JvkCPNLmGJHqlaA==", - "X-Forwarded-For": "127.0.0.1, 127.0.0.2", - "X-Forwarded-Port": "443", - "X-Forwarded-Proto": "https" - }, - "multiValueHeaders": {}, - "requestContext": { - "accountId": "123456789012", - "resourceId": "123456", - "stage": "Prod", - "requestId": "c6af9ac6-7b61-11e6-9a41-93e8deadbeef", - "requestTime": "25/Jul/2020:12:34:56 +0000", - "requestTimeEpoch": 1428582896000, - "identity": { - "cognitoIdentityPoolId": null, - "accountId": null, - "cognitoIdentityId": null, - "caller": null, - "accessKey": null, - "sourceIp": "127.0.0.1", - "cognitoAuthenticationType": null, - "cognitoAuthenticationProvider": null, - "userArn": null, - "userAgent": "Custom User Agent String", - "user": null - }, - "path": "/Prod/hello", - "resourcePath": "/hello", - "httpMethod": "POST", - "apiId": "1234567890", - "protocol": "HTTP/1.1" - } - } - ``` + This utility uses `path` and `httpMethod` to route to the right function. This helps make unit tests and local invocation easier too. + + ```json hl_lines="4-5" + { + "body": "hello", + "resource": "/hello", + "path": "/hello", + "httpMethod": "GET", + "isBase64Encoded": false, + "queryStringParameters": { + "foo": "bar" + }, + "multiValueQueryStringParameters": {}, + "pathParameters": { + "hello": "/hello" + }, + "stageVariables": {}, + "headers": { + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8", + "Accept-Encoding": "gzip, deflate, sdch", + "Accept-Language": "en-US,en;q=0.8", + "Cache-Control": "max-age=0", + "CloudFront-Forwarded-Proto": "https", + "CloudFront-Is-Desktop-Viewer": "true", + "CloudFront-Is-Mobile-Viewer": "false", + "CloudFront-Is-SmartTV-Viewer": "false", + "CloudFront-Is-Tablet-Viewer": "false", + "CloudFront-Viewer-Country": "US", + "Host": "1234567890.execute-api.us-east-1.amazonaws.com", + "Upgrade-Insecure-Requests": "1", + "User-Agent": "Custom User Agent String", + "Via": "1.1 08f323deadbeefa7af34d5feb414ce27.cloudfront.net (CloudFront)", + "X-Amz-Cf-Id": "cDehVQoZnx43VYQb9j2-nvCh-9z396Uhbp027Y2JvkCPNLmGJHqlaA==", + "X-Forwarded-For": "127.0.0.1, 127.0.0.2", + "X-Forwarded-Port": "443", + "X-Forwarded-Proto": "https" + }, + "multiValueHeaders": {}, + "requestContext": { + "accountId": "123456789012", + "resourceId": "123456", + "stage": "Prod", + "requestId": "c6af9ac6-7b61-11e6-9a41-93e8deadbeef", + "requestTime": "25/Jul/2020:12:34:56 +0000", + "requestTimeEpoch": 1428582896000, + "identity": { + "cognitoIdentityPoolId": null, + "accountId": null, + "cognitoIdentityId": null, + "caller": null, + "accessKey": null, + "sourceIp": "127.0.0.1", + "cognitoAuthenticationType": null, + "cognitoAuthenticationProvider": null, + "userArn": null, + "userAgent": "Custom User Agent String", + "user": null + }, + "path": "/Prod/hello", + "resourcePath": "/hello", + "httpMethod": "POST", + "apiId": "1234567890", + "protocol": "HTTP/1.1" + } + } + ``` === "response.json" - ```json - { - "statusCode": 200, - "headers": { - "Content-Type": "application/json" - }, - "body": "{\"message\":\"hello universe\"}", - "isBase64Encoded": false - } - ``` + ```json + { + "statusCode": 200, + "headers": { + "Content-Type": "application/json" + }, + "body": "{\"message\":\"hello universe\"}", + "isBase64Encoded": false + } + ``` #### HTTP API @@ -201,26 +201,26 @@ When using API Gateway HTTP API to front your Lambda functions, you can instruct === "app.py" - ```python hl_lines="3 7" - from aws_lambda_powertools import Logger, Tracer - from aws_lambda_powertools.logging import correlation_paths - from aws_lambda_powertools.event_handler.api_gateway import ApiGatewayResolver, ProxyEventType + ```python hl_lines="3 7" + from aws_lambda_powertools import Logger, Tracer + from aws_lambda_powertools.logging import correlation_paths + from aws_lambda_powertools.event_handler.api_gateway import ApiGatewayResolver, ProxyEventType - tracer = Tracer() - logger = Logger() - app = ApiGatewayResolver(proxy_type=ProxyEventType.APIGatewayProxyEventV2) + tracer = Tracer() + logger = Logger() + app = ApiGatewayResolver(proxy_type=ProxyEventType.APIGatewayProxyEventV2) - @app.get("/hello") - @tracer.capture_method - def get_hello_universe(): - return {"message": "hello universe"} + @app.get("/hello") + @tracer.capture_method + def get_hello_universe(): + return {"message": "hello universe"} - # You can continue to use other utilities just as before - @logger.inject_lambda_context(correlation_id_path=correlation_paths.API_GATEWAY_HTTP) - @tracer.capture_lambda_handler - def lambda_handler(event, context): - return app.resolve(event, context) - ``` + # You can continue to use other utilities just as before + @logger.inject_lambda_context(correlation_id_path=correlation_paths.API_GATEWAY_HTTP) + @tracer.capture_lambda_handler + def lambda_handler(event, context): + return app.resolve(event, context) + ``` #### ALB @@ -228,26 +228,26 @@ When using ALB to front your Lambda functions, you can instruct `ApiGatewayResol === "app.py" - ```python hl_lines="3 7" - from aws_lambda_powertools import Logger, Tracer - from aws_lambda_powertools.logging import correlation_paths - from aws_lambda_powertools.event_handler.api_gateway import ApiGatewayResolver, ProxyEventType + ```python hl_lines="3 7" + from aws_lambda_powertools import Logger, Tracer + from aws_lambda_powertools.logging import correlation_paths + from aws_lambda_powertools.event_handler.api_gateway import ApiGatewayResolver, ProxyEventType - tracer = Tracer() - logger = Logger() - app = ApiGatewayResolver(proxy_type=ProxyEventType.ALBEvent) + tracer = Tracer() + logger = Logger() + app = ApiGatewayResolver(proxy_type=ProxyEventType.ALBEvent) - @app.get("/hello") - @tracer.capture_method - def get_hello_universe(): - return {"message": "hello universe"} + @app.get("/hello") + @tracer.capture_method + def get_hello_universe(): + return {"message": "hello universe"} - # You can continue to use other utilities just as before - @logger.inject_lambda_context(correlation_id_path=correlation_paths.APPLICATION_LOAD_BALANCER) - @tracer.capture_lambda_handler - def lambda_handler(event, context): - return app.resolve(event, context) - ``` + # You can continue to use other utilities just as before + @logger.inject_lambda_context(correlation_id_path=correlation_paths.APPLICATION_LOAD_BALANCER) + @tracer.capture_lambda_handler + def lambda_handler(event, context): + return app.resolve(event, context) + ``` ### Dynamic routes @@ -255,35 +255,35 @@ You can use `/path/{dynamic_value}` when configuring dynamic URL paths. This all === "app.py" - ```python hl_lines="9 11" - from aws_lambda_powertools import Logger, Tracer - from aws_lambda_powertools.logging import correlation_paths - from aws_lambda_powertools.event_handler.api_gateway import ApiGatewayResolver + ```python hl_lines="9 11" + from aws_lambda_powertools import Logger, Tracer + from aws_lambda_powertools.logging import correlation_paths + from aws_lambda_powertools.event_handler.api_gateway import ApiGatewayResolver - tracer = Tracer() - logger = Logger() - app = ApiGatewayResolver() + tracer = Tracer() + logger = Logger() + app = ApiGatewayResolver() - @app.get("/hello/") - @tracer.capture_method - def get_hello_you(name): - return {"message": f"hello {name}"} + @app.get("/hello/") + @tracer.capture_method + def get_hello_you(name): + return {"message": f"hello {name}"} - # You can continue to use other utilities just as before - @logger.inject_lambda_context(correlation_id_path=correlation_paths.API_GATEWAY_REST) - @tracer.capture_lambda_handler - def lambda_handler(event, context): - return app.resolve(event, context) - ``` + # You can continue to use other utilities just as before + @logger.inject_lambda_context(correlation_id_path=correlation_paths.API_GATEWAY_REST) + @tracer.capture_lambda_handler + def lambda_handler(event, context): + return app.resolve(event, context) + ``` === "sample_request.json" - ```json + ```json { - "resource": "/hello/{name}", - "path": "/hello/lessa", - "httpMethod": "GET", - ... + "resource": "/hello/{name}", + "path": "/hello/lessa", + "httpMethod": "GET", + ... } ``` @@ -291,35 +291,35 @@ You can also nest paths as configured earlier in [our sample infrastructure](#re === "app.py" - ```python hl_lines="9 11" - from aws_lambda_powertools import Logger, Tracer - from aws_lambda_powertools.logging import correlation_paths - from aws_lambda_powertools.event_handler.api_gateway import ApiGatewayResolver + ```python hl_lines="9 11" + from aws_lambda_powertools import Logger, Tracer + from aws_lambda_powertools.logging import correlation_paths + from aws_lambda_powertools.event_handler.api_gateway import ApiGatewayResolver - tracer = Tracer() - logger = Logger() - app = ApiGatewayResolver() + tracer = Tracer() + logger = Logger() + app = ApiGatewayResolver() - @app.get("//") - @tracer.capture_method - def get_message(message, name): - return {"message": f"{message}, {name}}"} + @app.get("//") + @tracer.capture_method + def get_message(message, name): + return {"message": f"{message}, {name}}"} - # You can continue to use other utilities just as before - @logger.inject_lambda_context(correlation_id_path=correlation_paths.API_GATEWAY_REST) - @tracer.capture_lambda_handler - def lambda_handler(event, context): - return app.resolve(event, context) - ``` + # You can continue to use other utilities just as before + @logger.inject_lambda_context(correlation_id_path=correlation_paths.API_GATEWAY_REST) + @tracer.capture_lambda_handler + def lambda_handler(event, context): + return app.resolve(event, context) + ``` === "sample_request.json" - ```json + ```json { - "resource": "/{message}/{name}", - "path": "/hi/michael", - "httpMethod": "GET", - ... + "resource": "/{message}/{name}", + "path": "/hi/michael", + "httpMethod": "GET", + ... } ``` @@ -337,23 +337,23 @@ You can access the raw payload via `body` property, or if it's a JSON string you === "app.py" - ```python hl_lines="7-9 11" - from aws_lambda_powertools.event_handler.api_gateway import ApiGatewayResolver + ```python hl_lines="7-9 11" + from aws_lambda_powertools.event_handler.api_gateway import ApiGatewayResolver - app = ApiGatewayResolver() + app = ApiGatewayResolver() - @app.get("/hello") - def get_hello_you(): - query_strings_as_dict = app.current_event.query_string_parameters - json_payload = app.current_event.json_body - payload = app.current_event.body + @app.get("/hello") + def get_hello_you(): + query_strings_as_dict = app.current_event.query_string_parameters + json_payload = app.current_event.json_body + payload = app.current_event.body - name = app.current_event.get_query_string_value(name="name", default_value="") - return {"message": f"hello {name}}"} + name = app.current_event.get_query_string_value(name="name", default_value="") + return {"message": f"hello {name}}"} - def lambda_handler(event, context): - return app.resolve(event, context) - ``` + def lambda_handler(event, context): + return app.resolve(event, context) + ``` #### Headers @@ -361,21 +361,21 @@ Similarly to [Query strings](#query-strings-and-payload), you can access headers === "app.py" - ```python hl_lines="7-8" - from aws_lambda_powertools.event_handler.api_gateway import ApiGatewayResolver + ```python hl_lines="7-8" + from aws_lambda_powertools.event_handler.api_gateway import ApiGatewayResolver - app = ApiGatewayResolver() + app = ApiGatewayResolver() - @app.get("/hello") - def get_hello_you(): - headers_as_dict = app.current_event.headers - name = app.current_event.get_header_value(name="X-Name", default_value="") + @app.get("/hello") + def get_hello_you(): + headers_as_dict = app.current_event.headers + name = app.current_event.get_header_value(name="X-Name", default_value="") - return {"message": f"hello {name}}"} + return {"message": f"hello {name}}"} - def lambda_handler(event, context): - return app.resolve(event, context) - ``` + def lambda_handler(event, context): + return app.resolve(event, context) + ``` ### Raising HTTP errors @@ -385,13 +385,12 @@ You can easily raise any HTTP Error back to the client using `ServiceError` exce Additionally, we provide pre-defined errors for the most popular ones such as HTTP 400, 401, 404, 500. - === "app.py" - ```python hl_lines="4-10 20 25 30 35 39" - from aws_lambda_powertools import Logger, Tracer - from aws_lambda_powertools.logging import correlation_paths - from aws_lambda_powertools.event_handler.api_gateway import ApiGatewayResolver + ```python hl_lines="4-10 20 25 30 35 39" + from aws_lambda_powertools import Logger, Tracer + from aws_lambda_powertools.logging import correlation_paths + from aws_lambda_powertools.event_handler.api_gateway import ApiGatewayResolver from aws_lambda_powertools.event_handler.exceptions import ( BadRequestError, InternalServerError, @@ -400,43 +399,85 @@ Additionally, we provide pre-defined errors for the most popular ones such as HT UnauthorizedError, ) - tracer = Tracer() - logger = Logger() + tracer = Tracer() + logger = Logger() - app = ApiGatewayResolver() + app = ApiGatewayResolver() @app.get(rule="/bad-request-error") def bad_request_error(): - # HTTP 400 + # HTTP 400 raise BadRequestError("Missing required parameter") @app.get(rule="/unauthorized-error") def unauthorized_error(): - # HTTP 401 + # HTTP 401 raise UnauthorizedError("Unauthorized") @app.get(rule="/not-found-error") def not_found_error(): - # HTTP 404 + # HTTP 404 raise NotFoundError @app.get(rule="/internal-server-error") def internal_server_error(): - # HTTP 500 + # HTTP 500 raise InternalServerError("Internal server error") @app.get(rule="/service-error", cors=True) def service_error(): raise ServiceError(502, "Something went wrong!") - # alternatively - # from http import HTTPStatus - # raise ServiceError(HTTPStatus.BAD_GATEWAY.value, "Something went wrong) + # alternatively + # from http import HTTPStatus + # raise ServiceError(HTTPStatus.BAD_GATEWAY.value, "Something went wrong) def handler(event, context): return app.resolve(event, context) - ``` + ``` +### Custom Domain API Mappings + +When using Custom Domain API Mappings feature, you must use **`strip_prefixes`** param in the `ApiGatewayResolver` constructor. + +Scenario: You have a custom domain `api.mydomain.dev` and set an API Mapping `payment` to forward requests to your Payments API, the path argument will be `/payment/`. + +This will lead to a HTTP 404 despite having your Lambda configured correctly. See the example below on how to account for this change. + + +=== "app.py" + + ```python hl_lines="7" + from aws_lambda_powertools import Logger, Tracer + from aws_lambda_powertools.logging import correlation_paths + from aws_lambda_powertools.event_handler.api_gateway import ApiGatewayResolver + + tracer = Tracer() + logger = Logger() + app = ApiGatewayResolver(strip_prefixes=["/payment"]) + + @app.get("/subscriptions/") + @tracer.capture_method + def get_subscription(subscription): + return {"subscription_id": subscription} + + @logger.inject_lambda_context(correlation_id_path=correlation_paths.API_GATEWAY_REST) + @tracer.capture_lambda_handler + def lambda_handler(event, context): + return app.resolve(event, context) + ``` + +=== "sample_request.json" + + ```json + { + "resource": "/subscriptions/{subscription}", + "path": "/payment/subscriptions/123", + "httpMethod": "GET", + ... + } + ``` + ## Advanced ### CORS @@ -447,62 +488,61 @@ This will ensure that CORS headers are always returned as part of the response w === "app.py" - ```python hl_lines="9 11" - from aws_lambda_powertools import Logger, Tracer - from aws_lambda_powertools.logging import correlation_paths - from aws_lambda_powertools.event_handler.api_gateway import ApiGatewayResolver, CORSConfig + ```python hl_lines="9 11" + from aws_lambda_powertools import Logger, Tracer + from aws_lambda_powertools.logging import correlation_paths + from aws_lambda_powertools.event_handler.api_gateway import ApiGatewayResolver, CORSConfig - tracer = Tracer() - logger = Logger() + tracer = Tracer() + logger = Logger() - cors_config = CORSConfig(allow_origin="https://example.com", max_age=300) - app = ApiGatewayResolver(cors=cors_config) + cors_config = CORSConfig(allow_origin="https://example.com", max_age=300) + app = ApiGatewayResolver(cors=cors_config) - @app.get("/hello/") - @tracer.capture_method - def get_hello_you(name): - return {"message": f"hello {name}"} + @app.get("/hello/") + @tracer.capture_method + def get_hello_you(name): + return {"message": f"hello {name}"} - @app.get("/hello", cors=False) # optionally exclude CORS from response, if needed - @tracer.capture_method - def get_hello_no_cors_needed(): - return {"message": "hello, no CORS needed for this path ;)"} + @app.get("/hello", cors=False) # optionally exclude CORS from response, if needed + @tracer.capture_method + def get_hello_no_cors_needed(): + return {"message": "hello, no CORS needed for this path ;)"} - # You can continue to use other utilities just as before - @logger.inject_lambda_context(correlation_id_path=correlation_paths.API_GATEWAY_REST) - @tracer.capture_lambda_handler - def lambda_handler(event, context): - return app.resolve(event, context) - ``` + # You can continue to use other utilities just as before + @logger.inject_lambda_context(correlation_id_path=correlation_paths.API_GATEWAY_REST) + @tracer.capture_lambda_handler + def lambda_handler(event, context): + return app.resolve(event, context) + ``` === "response.json" - ```json - { - "statusCode": 200, - "headers": { - "Content-Type": "application/json", - "Access-Control-Allow-Origin": "https://www.example.com", - "Access-Control-Allow-Headers": "Authorization,Content-Type,X-Amz-Date,X-Amz-Security-Token,X-Api-Key" - }, - "body": "{\"message\":\"hello lessa\"}", - "isBase64Encoded": false - } - ``` + ```json + { + "statusCode": 200, + "headers": { + "Content-Type": "application/json", + "Access-Control-Allow-Origin": "https://www.example.com", + "Access-Control-Allow-Headers": "Authorization,Content-Type,X-Amz-Date,X-Amz-Security-Token,X-Api-Key" + }, + "body": "{\"message\":\"hello lessa\"}", + "isBase64Encoded": false + } + ``` === "response_no_cors.json" - ```json - { - "statusCode": 200, - "headers": { - "Content-Type": "application/json" - }, - "body": "{\"message\":\"hello lessa\"}", - "isBase64Encoded": false - } - ``` - + ```json + { + "statusCode": 200, + "headers": { + "Content-Type": "application/json" + }, + "body": "{\"message\":\"hello lessa\"}", + "isBase64Encoded": false + } + ``` !!! tip "Optionally disable class on a per path basis with `cors=False` parameter" @@ -532,25 +572,25 @@ You can use the `Response` class to have full control over the response, for exa === "app.py" - ```python hl_lines="10-14" - from aws_lambda_powertools.event_handler.api_gateway import ApiGatewayResolver, Response + ```python hl_lines="10-14" + from aws_lambda_powertools.event_handler.api_gateway import ApiGatewayResolver, Response - app = ApiGatewayResolver() + app = ApiGatewayResolver() - @app.get("/hello") - def get_hello_you(): - payload = json.dumps({"message": "I'm a teapot"}) - custom_headers = {"X-Custom": "X-Value"} + @app.get("/hello") + def get_hello_you(): + payload = json.dumps({"message": "I'm a teapot"}) + custom_headers = {"X-Custom": "X-Value"} - return Response(status_code=418, - content_type="application/json", - body=payload, - headers=custom_headers - ) + return Response(status_code=418, + content_type="application/json", + body=payload, + headers=custom_headers + ) - def lambda_handler(event, context): - return app.resolve(event, context) - ``` + def lambda_handler(event, context): + return app.resolve(event, context) + ``` === "response.json" @@ -559,7 +599,7 @@ You can use the `Response` class to have full control over the response, for exa "body": "{\"message\":\"I\'m a teapot\"}", "headers": { "Content-Type": "application/json", - "X-Custom": "X-Value" + "X-Custom": "X-Value" }, "isBase64Encoded": false, "statusCode": 418 @@ -573,29 +613,29 @@ You can compress with gzip and base64 encode your responses via `compress` param === "app.py" - ```python hl_lines="5 7" - from aws_lambda_powertools.event_handler.api_gateway import ApiGatewayResolver + ```python hl_lines="5 7" + from aws_lambda_powertools.event_handler.api_gateway import ApiGatewayResolver - app = ApiGatewayResolver() + app = ApiGatewayResolver() - @app.get("/hello", compress=True) - def get_hello_you(): - return {"message": "hello universe"} + @app.get("/hello", compress=True) + def get_hello_you(): + return {"message": "hello universe"} - def lambda_handler(event, context): - return app.resolve(event, context) - ``` + def lambda_handler(event, context): + return app.resolve(event, context) + ``` === "sample_request.json" - ```json + ```json { "headers": { "Accept-Encoding": "gzip" }, "httpMethod": "GET", "path": "/hello", - ... + ... } ``` @@ -623,74 +663,75 @@ Like `compress` feature, the client must send the `Accept` header with the corre === "app.py" - ```python hl_lines="4 7 11" - import os - from pathlib import Path + ```python hl_lines="4 7 11" + import os + from pathlib import Path - from aws_lambda_powertools.event_handler.api_gateway import ApiGatewayResolver, Response + from aws_lambda_powertools.event_handler.api_gateway import ApiGatewayResolver, Response - app = ApiGatewayResolver() - logo_file: bytes = Path(os.getenv("LAMBDA_TASK_ROOT") + "/logo.svg").read_bytes() + app = ApiGatewayResolver() + logo_file: bytes = Path(os.getenv("LAMBDA_TASK_ROOT") + "/logo.svg").read_bytes() - @app.get("/logo") - def get_logo(): - return Response(status_code=200, content_type="image/svg+xml", body=logo_file) + @app.get("/logo") + def get_logo(): + return Response(status_code=200, content_type="image/svg+xml", body=logo_file) - def lambda_handler(event, context): - return app.resolve(event, context) - ``` + def lambda_handler(event, context): + return app.resolve(event, context) + ``` === "logo.svg" - ```xml - - - - - - - - - - - - - ``` + + ```xml + + + + + + + + + + + + + ``` === "sample_request.json" - ```json + ```json { "headers": { "Accept": "image/svg+xml" }, "httpMethod": "GET", "path": "/logo", - ... + ... } ``` @@ -717,62 +758,62 @@ This will enable full tracebacks errors in the response, print request and respo === "debug.py" - ```python hl_lines="3" - from aws_lambda_powertools.event_handler.api_gateway import ApiGatewayResolver + ```python hl_lines="3" + from aws_lambda_powertools.event_handler.api_gateway import ApiGatewayResolver - app = ApiGatewayResolver(debug=True) + app = ApiGatewayResolver(debug=True) - @app.get("/hello") - def get_hello_universe(): - return {"message": "hello universe"} + @app.get("/hello") + def get_hello_universe(): + return {"message": "hello universe"} - def lambda_handler(event, context): - return app.resolve(event, context) - ``` + def lambda_handler(event, context): + return app.resolve(event, context) + ``` ### Custom serializer You can instruct API Gateway handler to use a custom serializer to best suit your needs, for example take into account Enums when serializing. === "custom_serializer.py" - ```python hl_lines="19-20 24" - import json - from enum import Enum - from json import JSONEncoder - from typing import Dict - - class CustomEncoder(JSONEncoder): - """Your customer json encoder""" - def default(self, obj): - if isinstance(obj, Enum): - return obj.value - try: - iterable = iter(obj) - except TypeError: - pass - else: - return sorted(iterable) - return JSONEncoder.default(self, obj) - - def custom_serializer(obj) -> str: - """Your custom serializer function ApiGatewayResolver will use""" - return json.dumps(obj, cls=CustomEncoder) - - # Assigning your custom serializer - app = ApiGatewayResolver(serializer=custom_serializer) - - class Color(Enum): - RED = 1 - BLUE = 2 - - @app.get("/colors") - def get_color() -> Dict: - return { - # Color.RED will be serialized to 1 as expected now - "color": Color.RED, - "variations": {"light", "dark"}, - } - ``` + ```python hl_lines="19-20 24" + import json + from enum import Enum + from json import JSONEncoder + from typing import Dict + + class CustomEncoder(JSONEncoder): + """Your customer json encoder""" + def default(self, obj): + if isinstance(obj, Enum): + return obj.value + try: + iterable = iter(obj) + except TypeError: + pass + else: + return sorted(iterable) + return JSONEncoder.default(self, obj) + + def custom_serializer(obj) -> str: + """Your custom serializer function ApiGatewayResolver will use""" + return json.dumps(obj, cls=CustomEncoder) + + # Assigning your custom serializer + app = ApiGatewayResolver(serializer=custom_serializer) + + class Color(Enum): + RED = 1 + BLUE = 2 + + @app.get("/colors") + def get_color() -> Dict: + return { + # Color.RED will be serialized to 1 as expected now + "color": Color.RED, + "variations": {"light", "dark"}, + } + ``` ## Testing your code @@ -780,54 +821,54 @@ You can test your routes by passing a proxy event request where `path` and `http === "test_app.py" - ```python hl_lines="18-24" - from dataclasses import dataclass + ```python hl_lines="18-24" + from dataclasses import dataclass - import pytest - import app + import pytest + import app @pytest.fixture def lambda_context(): - @dataclass - class LambdaContext: - function_name: str = "test" - memory_limit_in_mb: int = 128 - invoked_function_arn: str = "arn:aws:lambda:eu-west-1:809313241:function:test" - aws_request_id: str = "52fdfc07-2182-154f-163f-5f0f9a621d72" + @dataclass + class LambdaContext: + function_name: str = "test" + memory_limit_in_mb: int = 128 + invoked_function_arn: str = "arn:aws:lambda:eu-west-1:809313241:function:test" + aws_request_id: str = "52fdfc07-2182-154f-163f-5f0f9a621d72" return LambdaContext() def test_lambda_handler(lambda_context): - minimal_event = { - "path": "/hello", - "httpMethod": "GET" - "requestContext": { # correlation ID - "requestId": "c6af9ac6-7b61-11e6-9a41-93e8deadbeef" - } - } + minimal_event = { + "path": "/hello", + "httpMethod": "GET" + "requestContext": { # correlation ID + "requestId": "c6af9ac6-7b61-11e6-9a41-93e8deadbeef" + } + } app.lambda_handler(minimal_event, lambda_context) - ``` + ``` === "app.py" - ```python - from aws_lambda_powertools import Logger - from aws_lambda_powertools.logging import correlation_paths - from aws_lambda_powertools.event_handler.api_gateway import ApiGatewayResolver + ```python + from aws_lambda_powertools import Logger + from aws_lambda_powertools.logging import correlation_paths + from aws_lambda_powertools.event_handler.api_gateway import ApiGatewayResolver - logger = Logger() - app = ApiGatewayResolver() # by default API Gateway REST API (v1) + logger = Logger() + app = ApiGatewayResolver() # by default API Gateway REST API (v1) - @app.get("/hello") - def get_hello_universe(): - return {"message": "hello universe"} + @app.get("/hello") + def get_hello_universe(): + return {"message": "hello universe"} - # You can continue to use other utilities just as before - @logger.inject_lambda_context(correlation_id_path=correlation_paths.API_GATEWAY_REST) - def lambda_handler(event, context): - return app.resolve(event, context) - ``` + # You can continue to use other utilities just as before + @logger.inject_lambda_context(correlation_id_path=correlation_paths.API_GATEWAY_REST) + def lambda_handler(event, context): + return app.resolve(event, context) + ``` ## FAQ diff --git a/docs/core/event_handler/appsync.md b/docs/core/event_handler/appsync.md index a47b8a4c641..93bb7bf69a5 100644 --- a/docs/core/event_handler/appsync.md +++ b/docs/core/event_handler/appsync.md @@ -170,7 +170,6 @@ This is the sample infrastructure we are using for the initial examples with a A Value: !GetAtt HelloWorldApi.Arn ``` - ### Resolver decorator You can define your functions to match GraphQL types and fields with the `app.resolver()` decorator. @@ -238,6 +237,7 @@ Here's an example where we have two separate functions to resolve `getTodo` and ``` === "getTodo_event.json" + ```json { "arguments": { @@ -288,6 +288,7 @@ Here's an example where we have two separate functions to resolve `getTodo` and ``` === "listTodos_event.json" + ```json { "arguments": {}, @@ -395,6 +396,7 @@ You can nest `app.resolver()` decorator multiple times when resolving fields wit For Lambda Python3.8+ runtime, this utility supports async functions when you use in conjunction with `asyncio.run`. === "async_resolver.py" + ```python hl_lines="4 8 10-12 20" from aws_lambda_powertools import Logger, Tracer @@ -602,7 +604,6 @@ Use the following code for `merchantInfo` and `searchMerchant` functions respect You can subclass `AppSyncResolverEvent` to bring your own set of methods to handle incoming events, by using `data_model` param in the `resolve` method. - === "custom_model.py" ```python hl_lines="11-14 19 26" @@ -662,8 +663,8 @@ You can subclass `AppSyncResolverEvent` to bring your own set of methods to hand === "listLocations_event.json" - ```json - { + ```json + { "arguments": {}, "identity": null, "source": null, @@ -707,8 +708,8 @@ You can subclass `AppSyncResolverEvent` to bring your own set of methods to hand "variables": {} }, "stash": {} - } - ``` + } + ``` ## Testing your code @@ -719,6 +720,7 @@ You can use either `app.resolve(event, context)` or simply `app(event, context)` Here's an example from our internal functional test. === "test_direct_resolver.py" + ```python def test_direct_resolver(): # Check whether we can handle an example appsync direct resolver @@ -739,6 +741,7 @@ Here's an example from our internal functional test. ``` === "appSyncDirectResolver.json" + ```json --8<-- "tests/events/appSyncDirectResolver.json" ``` diff --git a/docs/core/logger.md b/docs/core/logger.md index 53818bada51..833d5a5c721 100644 --- a/docs/core/logger.md +++ b/docs/core/logger.md @@ -24,7 +24,8 @@ Setting | Description | Environment variable | Constructor parameter > Example using AWS Serverless Application Model (SAM) === "template.yaml" - ```yaml hl_lines="9 10" + + ```yaml hl_lines="9 10" Resources: HelloWorldFunction: Type: AWS::Serverless::Function @@ -34,13 +35,14 @@ Setting | Description | Environment variable | Constructor parameter Variables: LOG_LEVEL: INFO POWERTOOLS_SERVICE_NAME: example - ``` + ``` === "app.py" - ```python hl_lines="2 4" - from aws_lambda_powertools import Logger - logger = Logger() # Sets service via env var - # OR logger = Logger(service="example") - ``` + + ```python hl_lines="2 4" + from aws_lambda_powertools import Logger + logger = Logger() # Sets service via env var + # OR logger = Logger(service="example") + ``` ### Standard structured keys @@ -71,46 +73,46 @@ You can enrich your structured logs with key Lambda context information via `inj @logger.inject_lambda_context def handler(event, context): - logger.info("Collecting payment") + logger.info("Collecting payment") - # You can log entire objects too - logger.info({ + # You can log entire objects too + logger.info({ "operation": "collect_payment", "charge_id": event['charge_id'] - }) - ... + }) + ... ``` === "Example CloudWatch Logs excerpt" ```json hl_lines="7-11 16-19" - { - "level": "INFO", - "location": "collect.handler:7", - "message": "Collecting payment", - "timestamp": "2021-05-03 11:47:12,494+0200", - "service": "payment", - "cold_start": true, - "lambda_function_name": "test", - "lambda_function_memory_size": 128, - "lambda_function_arn": "arn:aws:lambda:eu-west-1:12345678910:function:test", - "lambda_request_id": "52fdfc07-2182-154f-163f-5f0f9a621d72" - }, - { - "level": "INFO", - "location": "collect.handler:10", - "message": { - "operation": "collect_payment", - "charge_id": "ch_AZFlk2345C0" - }, - "timestamp": "2021-05-03 11:47:12,494+0200", - "service": "payment", - "cold_start": true, - "lambda_function_name": "test", - "lambda_function_memory_size": 128, - "lambda_function_arn": "arn:aws:lambda:eu-west-1:12345678910:function:test", - "lambda_request_id": "52fdfc07-2182-154f-163f-5f0f9a621d72" - } + { + "level": "INFO", + "location": "collect.handler:7", + "message": "Collecting payment", + "timestamp": "2021-05-03 11:47:12,494+0200", + "service": "payment", + "cold_start": true, + "lambda_function_name": "test", + "lambda_function_memory_size": 128, + "lambda_function_arn": "arn:aws:lambda:eu-west-1:12345678910:function:test", + "lambda_request_id": "52fdfc07-2182-154f-163f-5f0f9a621d72" + }, + { + "level": "INFO", + "location": "collect.handler:10", + "message": { + "operation": "collect_payment", + "charge_id": "ch_AZFlk2345C0" + }, + "timestamp": "2021-05-03 11:47:12,494+0200", + "service": "payment", + "cold_start": true, + "lambda_function_name": "test", + "lambda_function_memory_size": 128, + "lambda_function_arn": "arn:aws:lambda:eu-west-1:12345678910:function:test", + "lambda_request_id": "52fdfc07-2182-154f-163f-5f0f9a621d72" + } ``` When used, this will include the following keys: @@ -151,42 +153,42 @@ You can set a Correlation ID using `correlation_id_path` param by passing a [JME === "collect.py" ```python hl_lines="5" - from aws_lambda_powertools import Logger + from aws_lambda_powertools import Logger - logger = Logger(service="payment") + logger = Logger(service="payment") - @logger.inject_lambda_context(correlation_id_path="headers.my_request_id_header") - def handler(event, context): - logger.debug(f"Correlation ID => {logger.get_correlation_id()}") - logger.info("Collecting payment") + @logger.inject_lambda_context(correlation_id_path="headers.my_request_id_header") + def handler(event, context): + logger.debug(f"Correlation ID => {logger.get_correlation_id()}") + logger.info("Collecting payment") ``` === "Example Event" - ```json hl_lines="3" - { - "headers": { - "my_request_id_header": "correlation_id_value" - } - } - ``` + ```json hl_lines="3" + { + "headers": { + "my_request_id_header": "correlation_id_value" + } + } + ``` === "Example CloudWatch Logs excerpt" ```json hl_lines="12" - { - "level": "INFO", - "location": "collect.handler:7", - "message": "Collecting payment", - "timestamp": "2021-05-03 11:47:12,494+0200", - "service": "payment", - "cold_start": true, - "lambda_function_name": "test", - "lambda_function_memory_size": 128, - "lambda_function_arn": "arn:aws:lambda:eu-west-1:12345678910:function:test", - "lambda_request_id": "52fdfc07-2182-154f-163f-5f0f9a621d72", - "correlation_id": "correlation_id_value" - } + { + "level": "INFO", + "location": "collect.handler:7", + "message": "Collecting payment", + "timestamp": "2021-05-03 11:47:12,494+0200", + "service": "payment", + "cold_start": true, + "lambda_function_name": "test", + "lambda_function_memory_size": 128, + "lambda_function_arn": "arn:aws:lambda:eu-west-1:12345678910:function:test", + "lambda_request_id": "52fdfc07-2182-154f-163f-5f0f9a621d72", + "correlation_id": "correlation_id_value" + } ``` We provide [built-in JMESPath expressions](#built-in-correlation-id-expressions) for known event sources, where either a request ID or X-Ray Trace ID are present. @@ -194,50 +196,49 @@ We provide [built-in JMESPath expressions](#built-in-correlation-id-expressions) === "collect.py" ```python hl_lines="2 6" - from aws_lambda_powertools import Logger - from aws_lambda_powertools.logging import correlation_paths + from aws_lambda_powertools import Logger + from aws_lambda_powertools.logging import correlation_paths - logger = Logger(service="payment") + logger = Logger(service="payment") - @logger.inject_lambda_context(correlation_id_path=correlation_paths.API_GATEWAY_REST) - def handler(event, context): - logger.debug(f"Correlation ID => {logger.get_correlation_id()}") - logger.info("Collecting payment") + @logger.inject_lambda_context(correlation_id_path=correlation_paths.API_GATEWAY_REST) + def handler(event, context): + logger.debug(f"Correlation ID => {logger.get_correlation_id()}") + logger.info("Collecting payment") ``` === "Example Event" - ```json hl_lines="3" - { - "requestContext": { - "requestId": "correlation_id_value" - } - } - ``` + ```json hl_lines="3" + { + "requestContext": { + "requestId": "correlation_id_value" + } + } + ``` === "Example CloudWatch Logs excerpt" ```json hl_lines="12" - { - "level": "INFO", - "location": "collect.handler:8", - "message": "Collecting payment", - "timestamp": "2021-05-03 11:47:12,494+0200", - "service": "payment", - "cold_start": true, - "lambda_function_name": "test", - "lambda_function_memory_size": 128, - "lambda_function_arn": "arn:aws:lambda:eu-west-1:12345678910:function:test", - "lambda_request_id": "52fdfc07-2182-154f-163f-5f0f9a621d72", - "correlation_id": "correlation_id_value" - } + { + "level": "INFO", + "location": "collect.handler:8", + "message": "Collecting payment", + "timestamp": "2021-05-03 11:47:12,494+0200", + "service": "payment", + "cold_start": true, + "lambda_function_name": "test", + "lambda_function_memory_size": 128, + "lambda_function_arn": "arn:aws:lambda:eu-west-1:12345678910:function:test", + "lambda_request_id": "52fdfc07-2182-154f-163f-5f0f9a621d72", + "correlation_id": "correlation_id_value" + } ``` ### Appending additional keys !!! info "Custom keys are persisted across warm invocations" - Always set additional keys as part of your handler to ensure they have the latest value, or explicitly clear them with [`clear_state=True`](#clearing-all-state). - + Always set additional keys as part of your handler to ensure they have the latest value, or explicitly clear them with [`clear_state=True`](#clearing-all-state). You can append additional keys using either mechanism: @@ -258,30 +259,30 @@ You can append your own keys to your existing Logger via `append_keys(**addition logger = Logger(service="payment") def handler(event, context): - order_id = event.get("order_id") + order_id = event.get("order_id") - # this will ensure order_id key always has the latest value before logging - logger.append_keys(order_id=order_id) + # this will ensure order_id key always has the latest value before logging + logger.append_keys(order_id=order_id) - logger.info("Collecting payment") + logger.info("Collecting payment") ``` === "Example CloudWatch Logs excerpt" ```json hl_lines="7" - { - "level": "INFO", - "location": "collect.handler:11", - "message": "Collecting payment", - "timestamp": "2021-05-03 11:47:12,494+0200", - "service": "payment", - "order_id": "order_id_value" - } + { + "level": "INFO", + "location": "collect.handler:11", + "message": "Collecting payment", + "timestamp": "2021-05-03 11:47:12,494+0200", + "service": "payment", + "order_id": "order_id_value" + } ``` !!! tip "Logger will automatically reject any key with a None value" - If you conditionally add keys depending on the payload, you can follow the example above. + If you conditionally add keys depending on the payload, you can follow the example above. - This example will add `order_id` if its value is not empty, and in subsequent invocations where `order_id` might not be present it'll remove it from the Logger. + This example will add `order_id` if its value is not empty, and in subsequent invocations where `order_id` might not be present it'll remove it from the Logger. #### extra parameter @@ -304,14 +305,14 @@ It accepts any dictionary, and all keyword arguments will be added as part of th === "Example CloudWatch Logs excerpt" ```json hl_lines="7" - { - "level": "INFO", - "location": "collect.handler:6", - "message": "Collecting payment", - "timestamp": "2021-05-03 11:47:12,494+0200", - "service": "payment", - "request_id": "1123" - } + { + "level": "INFO", + "location": "collect.handler:6", + "message": "Collecting payment", + "timestamp": "2021-05-03 11:47:12,494+0200", + "service": "payment", + "request_id": "1123" + } ``` #### set_correlation_id method @@ -321,36 +322,36 @@ You can set a correlation_id to your existing Logger via `set_correlation_id(val === "collect.py" ```python hl_lines="6" - from aws_lambda_powertools import Logger + from aws_lambda_powertools import Logger - logger = Logger(service="payment") + logger = Logger(service="payment") - def handler(event, context): - logger.set_correlation_id(event["requestContext"]["requestId"]) - logger.info("Collecting payment") + def handler(event, context): + logger.set_correlation_id(event["requestContext"]["requestId"]) + logger.info("Collecting payment") ``` === "Example Event" - ```json hl_lines="3" - { - "requestContext": { - "requestId": "correlation_id_value" - } - } - ``` + ```json hl_lines="3" + { + "requestContext": { + "requestId": "correlation_id_value" + } + } + ``` === "Example CloudWatch Logs excerpt" ```json hl_lines="7" - { - "level": "INFO", - "location": "collect.handler:7", - "message": "Collecting payment", - "timestamp": "2021-05-03 11:47:12,494+0200", - "service": "payment", - "correlation_id": "correlation_id_value" - } + { + "level": "INFO", + "location": "collect.handler:7", + "message": "Collecting payment", + "timestamp": "2021-05-03 11:47:12,494+0200", + "service": "payment", + "correlation_id": "correlation_id_value" + } ``` Alternatively, you can combine [Data Classes utility](../utilities/data_classes.md) with Logger to use dot notation object: @@ -358,38 +359,38 @@ Alternatively, you can combine [Data Classes utility](../utilities/data_classes. === "collect.py" ```python hl_lines="2 7-8" - from aws_lambda_powertools import Logger - from aws_lambda_powertools.utilities.data_classes import APIGatewayProxyEvent + from aws_lambda_powertools import Logger + from aws_lambda_powertools.utilities.data_classes import APIGatewayProxyEvent - logger = Logger(service="payment") + logger = Logger(service="payment") - def handler(event, context): - event = APIGatewayProxyEvent(event) - logger.set_correlation_id(event.request_context.request_id) - logger.info("Collecting payment") + def handler(event, context): + event = APIGatewayProxyEvent(event) + logger.set_correlation_id(event.request_context.request_id) + logger.info("Collecting payment") ``` === "Example Event" - ```json hl_lines="3" - { - "requestContext": { - "requestId": "correlation_id_value" - } - } - ``` + ```json hl_lines="3" + { + "requestContext": { + "requestId": "correlation_id_value" + } + } + ``` === "Example CloudWatch Logs excerpt" ```json hl_lines="7" - { - "timestamp": "2020-05-24 18:17:33,774", - "level": "INFO", - "location": "collect.handler:9", - "service": "payment", - "sampling_rate": 0.0, - "correlation_id": "correlation_id_value", - "message": "Collecting payment" - } + { + "timestamp": "2020-05-24 18:17:33,774", + "level": "INFO", + "location": "collect.handler:9", + "service": "payment", + "sampling_rate": 0.0, + "correlation_id": "correlation_id_value", + "message": "Collecting payment" + } ``` ### Removing additional keys @@ -405,30 +406,30 @@ You can remove any additional key from Logger state using `remove_keys`. def handler(event, context): logger.append_keys(sample_key="value") - logger.info("Collecting payment") + logger.info("Collecting payment") - logger.remove_keys(["sample_key"]) - logger.info("Collecting payment without sample key") + logger.remove_keys(["sample_key"]) + logger.info("Collecting payment without sample key") ``` === "Example CloudWatch Logs excerpt" ```json hl_lines="7" - { - "level": "INFO", - "location": "collect.handler:7", - "message": "Collecting payment", - "timestamp": "2021-05-03 11:47:12,494+0200", - "service": "payment", - "sample_key": "value" - }, - { - "level": "INFO", - "location": "collect.handler:10", - "message": "Collecting payment without sample key", - "timestamp": "2021-05-03 11:47:12,494+0200", - "service": "payment" - } + { + "level": "INFO", + "location": "collect.handler:7", + "message": "Collecting payment", + "timestamp": "2021-05-03 11:47:12,494+0200", + "service": "payment", + "sample_key": "value" + }, + { + "level": "INFO", + "location": "collect.handler:10", + "message": "Collecting payment without sample key", + "timestamp": "2021-05-03 11:47:12,494+0200", + "service": "payment" + } ``` #### Clearing all state @@ -436,14 +437,14 @@ You can remove any additional key from Logger state using `remove_keys`. Logger is commonly initialized in the global scope. Due to [Lambda Execution Context reuse](https://docs.aws.amazon.com/lambda/latest/dg/runtimes-context.html), this means that custom keys can be persisted across invocations. If you want all custom keys to be deleted, you can use `clear_state=True` param in `inject_lambda_context` decorator. !!! info - This is useful when you add multiple custom keys conditionally, instead of setting a default `None` value if not present. Any key with `None` value is automatically removed by Logger. + This is useful when you add multiple custom keys conditionally, instead of setting a default `None` value if not present. Any key with `None` value is automatically removed by Logger. !!! danger "This can have unintended side effects if you use Layers" - Lambda Layers code is imported before the Lambda handler. + Lambda Layers code is imported before the Lambda handler. - This means that `clear_state=True` will instruct Logger to remove any keys previously added before Lambda handler execution proceeds. + This means that `clear_state=True` will instruct Logger to remove any keys previously added before Lambda handler execution proceeds. - You can either avoid running any code as part of Lambda Layers global scope, or override keys with their latest value as part of handler's execution. + You can either avoid running any code as part of Lambda Layers global scope, or override keys with their latest value as part of handler's execution. === "collect.py" @@ -454,81 +455,80 @@ Logger is commonly initialized in the global scope. Due to [Lambda Execution Con @logger.inject_lambda_context(clear_state=True) def handler(event, context): - if event.get("special_key"): - # Should only be available in the first request log - # as the second request doesn't contain `special_key` - logger.append_keys(debugging_key="value") + if event.get("special_key"): + # Should only be available in the first request log + # as the second request doesn't contain `special_key` + logger.append_keys(debugging_key="value") - logger.info("Collecting payment") + logger.info("Collecting payment") ``` === "#1 request" ```json hl_lines="7" - { - "level": "INFO", - "location": "collect.handler:10", - "message": "Collecting payment", - "timestamp": "2021-05-03 11:47:12,494+0200", - "service": "payment", - "special_key": "debug_key", - "cold_start": true, - "lambda_function_name": "test", - "lambda_function_memory_size": 128, - "lambda_function_arn": "arn:aws:lambda:eu-west-1:12345678910:function:test", - "lambda_request_id": "52fdfc07-2182-154f-163f-5f0f9a621d72" - } + { + "level": "INFO", + "location": "collect.handler:10", + "message": "Collecting payment", + "timestamp": "2021-05-03 11:47:12,494+0200", + "service": "payment", + "special_key": "debug_key", + "cold_start": true, + "lambda_function_name": "test", + "lambda_function_memory_size": 128, + "lambda_function_arn": "arn:aws:lambda:eu-west-1:12345678910:function:test", + "lambda_request_id": "52fdfc07-2182-154f-163f-5f0f9a621d72" + } ``` === "#2 request" - ```json hl_lines="7" - { - "level": "INFO", - "location": "collect.handler:10", - "message": "Collecting payment", - "timestamp": "2021-05-03 11:47:12,494+0200", - "service": "payment", - "cold_start": false, - "lambda_function_name": "test", - "lambda_function_memory_size": 128, - "lambda_function_arn": "arn:aws:lambda:eu-west-1:12345678910:function:test", - "lambda_request_id": "52fdfc07-2182-154f-163f-5f0f9a621d72" - } + ```json hl_lines="7" + { + "level": "INFO", + "location": "collect.handler:10", + "message": "Collecting payment", + "timestamp": "2021-05-03 11:47:12,494+0200", + "service": "payment", + "cold_start": false, + "lambda_function_name": "test", + "lambda_function_memory_size": 128, + "lambda_function_arn": "arn:aws:lambda:eu-west-1:12345678910:function:test", + "lambda_request_id": "52fdfc07-2182-154f-163f-5f0f9a621d72" + } ``` - ### Logging exceptions Use `logger.exception` method to log contextual information about exceptions. Logger will include `exception_name` and `exception` keys to aid troubleshooting and error enumeration. !!! tip - You can use your preferred Log Analytics tool to enumerate and visualize exceptions across all your services using `exception_name` key. + You can use your preferred Log Analytics tool to enumerate and visualize exceptions across all your services using `exception_name` key. === "collect.py" ```python hl_lines="8" from aws_lambda_powertools import Logger - logger = Logger(service="payment") + logger = Logger(service="payment") try: - raise ValueError("something went wrong") + raise ValueError("something went wrong") except Exception: - logger.exception("Received an exception") + logger.exception("Received an exception") ``` === "Example CloudWatch Logs excerpt" ```json hl_lines="7-8" { - "level": "ERROR", - "location": "collect.handler:5", - "message": "Received an exception", - "timestamp": "2021-05-03 11:47:12,494+0200", - "service": "payment", - "exception_name": "ValueError", - "exception": "Traceback (most recent call last):\n File \"\", line 2, in \nValueError: something went wrong" + "level": "ERROR", + "location": "collect.handler:5", + "message": "Received an exception", + "timestamp": "2021-05-03 11:47:12,494+0200", + "service": "payment", + "exception_name": "ValueError", + "exception": "Traceback (most recent call last):\n File \"\", line 2, in \nValueError: something went wrong" } ``` @@ -547,8 +547,8 @@ Logger supports inheritance via `child` parameter. This allows you to create mul logger = Logger() # POWERTOOLS_SERVICE_NAME: "payment" def handler(event, context): - shared.inject_payment_id(event) - ... + shared.inject_payment_id(event) + ... ``` === "shared.py" @@ -565,7 +565,7 @@ Logger supports inheritance via `child` parameter. This allows you to create mul In this example, `Logger` will create a parent logger named `payment` and a child logger named `payment.shared`. Changes in either parent or child logger will be propagated bi-directionally. !!! info "Child loggers will be named after the following convention `{service}.{filename}`" - If you forget to use `child` param but the `service` name is the same of the parent, we will return the existing parent `Logger` instead. + If you forget to use `child` param but the `service` name is the same of the parent, we will return the existing parent `Logger` instead. ### Sampling debug logs @@ -574,9 +574,9 @@ Use sampling when you want to dynamically change your log level to **DEBUG** bas You can use values ranging from `0.0` to `1` (100%) when setting `POWERTOOLS_LOGGER_SAMPLE_RATE` env var or `sample_rate` parameter in Logger. !!! tip "When is this useful?" - Let's imagine a sudden spike increase in concurrency triggered a transient issue downstream. When looking into the logs you might not have enough information, and while you can adjust log levels it might not happen again. + Let's imagine a sudden spike increase in concurrency triggered a transient issue downstream. When looking into the logs you might not have enough information, and while you can adjust log levels it might not happen again. - This feature takes into account transient issues where additional debugging information can be useful. + This feature takes into account transient issues where additional debugging information can be useful. Sampling decision happens at the Logger initialization. This means sampling may happen significantly more or less than depending on your traffic patterns, for example a steady low number of invocations and thus few cold starts. @@ -592,38 +592,38 @@ Sampling decision happens at the Logger initialization. This means sampling may logger = Logger(service="payment", sample_rate=0.1) def handler(event, context): - logger.debug("Verifying whether order_id is present") - logger.info("Collecting payment") + logger.debug("Verifying whether order_id is present") + logger.info("Collecting payment") ``` === "Example CloudWatch Logs excerpt" ```json hl_lines="2 4 12 15 25" { - "level": "DEBUG", - "location": "collect.handler:7", - "message": "Verifying whether order_id is present", - "timestamp": "2021-05-03 11:47:12,494+0200", - "service": "payment", - "cold_start": true, - "lambda_function_name": "test", - "lambda_function_memory_size": 128, - "lambda_function_arn": "arn:aws:lambda:eu-west-1:12345678910:function:test", - "lambda_request_id": "52fdfc07-2182-154f-163f-5f0f9a621d72", - "sampling_rate": 0.1 + "level": "DEBUG", + "location": "collect.handler:7", + "message": "Verifying whether order_id is present", + "timestamp": "2021-05-03 11:47:12,494+0200", + "service": "payment", + "cold_start": true, + "lambda_function_name": "test", + "lambda_function_memory_size": 128, + "lambda_function_arn": "arn:aws:lambda:eu-west-1:12345678910:function:test", + "lambda_request_id": "52fdfc07-2182-154f-163f-5f0f9a621d72", + "sampling_rate": 0.1 }, { - "level": "INFO", - "location": "collect.handler:7", - "message": "Collecting payment", - "timestamp": "2021-05-03 11:47:12,494+0200", - "service": "payment", - "cold_start": true, - "lambda_function_name": "test", - "lambda_function_memory_size": 128, - "lambda_function_arn": "arn:aws:lambda:eu-west-1:12345678910:function:test", - "lambda_request_id": "52fdfc07-2182-154f-163f-5f0f9a621d72", - "sampling_rate": 0.1 + "level": "INFO", + "location": "collect.handler:7", + "message": "Collecting payment", + "timestamp": "2021-05-03 11:47:12,494+0200", + "service": "payment", + "cold_start": true, + "lambda_function_name": "test", + "lambda_function_memory_size": 128, + "lambda_function_arn": "arn:aws:lambda:eu-west-1:12345678910:function:test", + "lambda_request_id": "52fdfc07-2182-154f-163f-5f0f9a621d72", + "sampling_rate": 0.1 } ``` @@ -647,9 +647,9 @@ Parameter | Description | Default ```python hl_lines="2 4-5" from aws_lambda_powertools import Logger - from aws_lambda_powertools.logging.formatter import LambdaPowertoolsFormatter + from aws_lambda_powertools.logging.formatter import LambdaPowertoolsFormatter - formatter = LambdaPowertoolsFormatter(utc=True, log_record_order=["message"]) + formatter = LambdaPowertoolsFormatter(utc=True, log_record_order=["message"]) logger = Logger(service="example", logger_formatter=formatter) ``` @@ -672,7 +672,7 @@ For inheritance, Logger uses a `child=True` parameter along with `service` being For child Loggers, we introspect the name of your module where `Logger(child=True, service="name")` is called, and we name your Logger as **{service}.{filename}**. !!! danger - A common issue when migrating from other Loggers is that `service` might be defined in the parent Logger (no child param), and not defined in the child Logger: + A common issue when migrating from other Loggers is that `service` might be defined in the parent Logger (no child param), and not defined in the child Logger: === "incorrect_logger_inheritance.py" @@ -707,7 +707,7 @@ For child Loggers, we introspect the name of your module where `Logger(child=Tru In this case, Logger will register a Logger named `payment`, and a Logger named `service_undefined`. The latter isn't inheriting from the parent, and will have no handler, resulting in no message being logged to standard output. !!! tip - This can be fixed by either ensuring both has the `service` value as `payment`, or simply use the environment variable `POWERTOOLS_SERVICE_NAME` to ensure service value will be the same across all Loggers when not explicitly set. + This can be fixed by either ensuring both has the `service` value as `payment`, or simply use the environment variable `POWERTOOLS_SERVICE_NAME` to ensure service value will be the same across all Loggers when not explicitly set. #### Overriding Log records @@ -716,13 +716,13 @@ You might want to continue to use the same date formatting style, or override `l Logger allows you to either change the format or suppress the following keys altogether at the initialization: `location`, `timestamp`, `level`, `xray_trace_id`. === "lambda_handler.py" - > We honour standard [logging library string formats](https://docs.python.org/3/howto/logging.html#displaying-the-date-time-in-messages){target="_blank"}. + > We honour standard [logging library string formats](https://docs.python.org/3/howto/logging.html#displaying-the-date-time-in-messages){target="_blank"}. ```python hl_lines="7 10" from aws_lambda_powertools import Logger - date_format = "%m/%d/%Y %I:%M:%S %p" - location_format = "[%(funcName)s] %(module)s" + date_format = "%m/%d/%Y %I:%M:%S %p" + location_format = "[%(funcName)s] %(module)s" # override location and timestamp format logger = Logger(service="payment", location=location_format, datefmt=date_format) @@ -730,18 +730,19 @@ Logger allows you to either change the format or suppress the following keys alt # suppress the location key with a None value logger_two = Logger(service="payment", location=None) - logger.info("Collecting payment") + logger.info("Collecting payment") ``` === "Example CloudWatch Logs excerpt" - ```json hl_lines="3 5" - { - "level": "INFO", - "location": "[] lambda_handler", - "message": "Collecting payment", - "timestamp": "02/09/2021 09:25:17 AM", - "service": "payment" - } - ``` + + ```json hl_lines="3 5" + { + "level": "INFO", + "location": "[] lambda_handler", + "message": "Collecting payment", + "timestamp": "02/09/2021 09:25:17 AM", + "service": "payment" + } + ``` #### Reordering log keys position @@ -755,23 +756,24 @@ You can change the order of [standard Logger keys](#standard-structured-keys) or # make message as the first key logger = Logger(service="payment", log_record_order=["message"]) - # make request_id that will be added later as the first key - # Logger(service="payment", log_record_order=["request_id"]) + # make request_id that will be added later as the first key + # Logger(service="payment", log_record_order=["request_id"]) # Default key sorting order when omit # Logger(service="payment", log_record_order=["level","location","message","timestamp"]) ``` === "Example CloudWatch Logs excerpt" - ```json hl_lines="3 5" - { - "message": "hello world", - "level": "INFO", - "location": "[]:6", - "timestamp": "2021-02-09 09:36:12,280", - "service": "service_undefined", - "sampling_rate": 0.0 - } - ``` + + ```json hl_lines="3 5" + { + "message": "hello world", + "level": "INFO", + "location": "[]:6", + "timestamp": "2021-02-09 09:36:12,280", + "service": "service_undefined", + "sampling_rate": 0.0 + } + ``` #### Setting timestamp to UTC @@ -798,27 +800,28 @@ By default, Logger uses `str` to handle values non-serializable by JSON. You can ```python hl_lines="3-4 9 12" from aws_lambda_powertools import Logger - def custom_json_default(value): - return f"" + def custom_json_default(value): + return f"" - class Unserializable: - pass + class Unserializable: + pass logger = Logger(service="payment", json_default=custom_json_default) - def handler(event, context): - logger.info(Unserializable()) + def handler(event, context): + logger.info(Unserializable()) ``` === "Example CloudWatch Logs excerpt" - ```json hl_lines="4" - { - "level": "INFO", - "location": "collect.handler:8", - "message": """", - "timestamp": "2021-05-03 15:17:23,632+0200", - "service": "payment" - } - ``` + + ```json hl_lines="4" + { + "level": "INFO", + "location": "collect.handler:8", + "message": """", + "timestamp": "2021-05-03 15:17:23,632+0200", + "service": "payment" + } + ``` #### Bring your own handler @@ -827,16 +830,16 @@ By default, Logger uses StreamHandler and logs to standard output. You can overr === "collect.py" ```python hl_lines="3-4 9 12" - import logging - from pathlib import Path + import logging + from pathlib import Path - from aws_lambda_powertools import Logger + from aws_lambda_powertools import Logger - log_file = Path("/tmp/log.json") - log_file_handler = logging.FileHandler(filename=log_file) + log_file = Path("/tmp/log.json") + log_file_handler = logging.FileHandler(filename=log_file) logger = Logger(service="payment", logger_handler=log_file_handler) - logger.info("Collecting payment") + logger.info("Collecting payment") ``` #### Bring your own formatter @@ -847,48 +850,48 @@ For **minor changes like remapping keys** after all log record processing has co === "custom_formatter.py" - ```python - from aws_lambda_powertools import Logger - from aws_lambda_powertools.logging.formatter import LambdaPowertoolsFormatter + ```python + from aws_lambda_powertools import Logger + from aws_lambda_powertools.logging.formatter import LambdaPowertoolsFormatter - from typing import Dict + from typing import Dict - class CustomFormatter(LambdaPowertoolsFormatter): - def serialize(self, log: Dict) -> str: - """Serialize final structured log dict to JSON str""" - log["event"] = log.pop("message") # rename message key to event - return self.json_serializer(log) # use configured json serializer + class CustomFormatter(LambdaPowertoolsFormatter): + def serialize(self, log: Dict) -> str: + """Serialize final structured log dict to JSON str""" + log["event"] = log.pop("message") # rename message key to event + return self.json_serializer(log) # use configured json serializer - my_formatter = CustomFormatter() - logger = Logger(service="example", logger_formatter=my_formatter) - logger.info("hello") - ``` + my_formatter = CustomFormatter() + logger = Logger(service="example", logger_formatter=my_formatter) + logger.info("hello") + ``` For **replacing the formatter entirely**, you can subclass `BasePowertoolsFormatter`, implement `append_keys` method, and override `format` standard logging method. This ensures the current feature set of Logger like [injecting Lambda context](#capturing-lambda-context-info) and [sampling](#sampling-debug-logs) will continue to work. !!! info - You might need to implement `remove_keys` method if you make use of the feature too. + You might need to implement `remove_keys` method if you make use of the feature too. === "collect.py" ```python hl_lines="2 4 7 12 16 27" - from aws_lambda_powertools import Logger - from aws_lambda_powertools.logging.formatter import BasePowertoolsFormatter + from aws_lambda_powertools import Logger + from aws_lambda_powertools.logging.formatter import BasePowertoolsFormatter class CustomFormatter(BasePowertoolsFormatter): custom_format = {} # arbitrary dict to hold our structured keys def append_keys(self, **additional_keys): - # also used by `inject_lambda_context` decorator + # also used by `inject_lambda_context` decorator self.custom_format.update(additional_keys) - # Optional unless you make use of this Logger feature + # Optional unless you make use of this Logger feature def remove_keys(self, keys: Iterable[str]): for key in keys: self.custom_format.pop(key, None) def format(self, record: logging.LogRecord) -> str: # noqa: A003 - """Format logging record as structured JSON str""" + """Format logging record as structured JSON str""" return json.dumps( { "event": super().format(record), @@ -902,20 +905,20 @@ For **replacing the formatter entirely**, you can subclass `BasePowertoolsFormat @logger.inject_lambda_context def handler(event, context): - logger.info("Collecting payment") + logger.info("Collecting payment") ``` === "Example CloudWatch Logs excerpt" ```json hl_lines="2-4" { - "event": "Collecting payment", - "timestamp": "2021-05-03 11:47:12,494", - "my_default_key": "test", - "cold_start": true, - "lambda_function_name": "test", - "lambda_function_memory_size": 128, - "lambda_function_arn": "arn:aws:lambda:eu-west-1:12345678910:function:test", - "lambda_request_id": "52fdfc07-2182-154f-163f-5f0f9a621d72" + "event": "Collecting payment", + "timestamp": "2021-05-03 11:47:12,494", + "my_default_key": "test", + "cold_start": true, + "lambda_function_name": "test", + "lambda_function_memory_size": 128, + "lambda_function_arn": "arn:aws:lambda:eu-west-1:12345678910:function:test", + "lambda_request_id": "52fdfc07-2182-154f-163f-5f0f9a621d72" } ``` @@ -928,20 +931,20 @@ As parameters don't always translate well between them, you can pass any callabl === "collect.py" ```python hl_lines="1 5-6 9-10" - import orjson + import orjson - from aws_lambda_powertools import Logger + from aws_lambda_powertools import Logger - custom_serializer = orjson.dumps - custom_deserializer = orjson.loads + custom_serializer = orjson.dumps + custom_deserializer = orjson.loads logger = Logger(service="payment", json_serializer=custom_serializer, json_deserializer=custom_deserializer - ) + ) - # when using parameters, you can pass a partial - # custom_serializer=functools.partial(orjson.dumps, option=orjson.OPT_SERIALIZE_NUMPY) + # when using parameters, you can pass a partial + # custom_serializer=functools.partial(orjson.dumps, option=orjson.OPT_SERIALIZE_NUMPY) ``` ## Built-in Correlation ID expressions @@ -949,7 +952,7 @@ As parameters don't always translate well between them, you can pass any callabl You can use any of the following built-in JMESPath expressions as part of [inject_lambda_context decorator](#setting-a-correlation-id). !!! note "Escaping necessary for the `-` character" - Any object key named with `-` must be escaped, for example **`request.headers."x-amzn-trace-id"`**. + Any object key named with `-` must be escaped, for example **`request.headers."x-amzn-trace-id"`**. Name | Expression | Description ------------------------------------------------- | ------------------------------------------------- | --------------------------------------------------------------------------------- @@ -968,21 +971,21 @@ When unit testing your code that makes use of `inject_lambda_context` decorator, This is a Pytest sample that provides the minimum information necessary for Logger to succeed: === "fake_lambda_context_for_logger.py" - Note that dataclasses are available in Python 3.7+ only. + Note that dataclasses are available in Python 3.7+ only. ```python - from dataclasses import dataclass + from dataclasses import dataclass - import pytest + import pytest @pytest.fixture def lambda_context(): - @dataclass - class LambdaContext: - function_name: str = "test" - memory_limit_in_mb: int = 128 - invoked_function_arn: str = "arn:aws:lambda:eu-west-1:809313241:function:test" - aws_request_id: str = "52fdfc07-2182-154f-163f-5f0f9a621d72" + @dataclass + class LambdaContext: + function_name: str = "test" + memory_limit_in_mb: int = 128 + invoked_function_arn: str = "arn:aws:lambda:eu-west-1:809313241:function:test" + aws_request_id: str = "52fdfc07-2182-154f-163f-5f0f9a621d72" return LambdaContext() @@ -991,10 +994,11 @@ This is a Pytest sample that provides the minimum information necessary for Logg your_lambda_handler(test_event, lambda_context) # this will now have a Context object populated ``` === "fake_lambda_context_for_logger_py36.py" + ```python - from collections import namedtuple + from collections import namedtuple - import pytest + import pytest @pytest.fixture def lambda_context(): @@ -1010,20 +1014,22 @@ This is a Pytest sample that provides the minimum information necessary for Logg def test_lambda_handler(lambda_context): test_event = {'test': 'event'} - # this will now have a Context object populated - your_lambda_handler(test_event, lambda_context) + # this will now have a Context object populated + your_lambda_handler(test_event, lambda_context) ``` !!! tip - If you're using pytest and are looking to assert plain log messages, do check out the built-in [caplog fixture](https://docs.pytest.org/en/latest/how-to/logging.html){target="_blank"}. + If you're using pytest and are looking to assert plain log messages, do check out the built-in [caplog fixture](https://docs.pytest.org/en/latest/how-to/logging.html){target="_blank"}. ### Pytest live log feature Pytest Live Log feature duplicates emitted log messages in order to style log statements according to their levels, for this to work use `POWERTOOLS_LOG_DEDUPLICATION_DISABLED` env var. -```bash -POWERTOOLS_LOG_DEDUPLICATION_DISABLED="1" pytest -o log_cli=1 -``` +=== "shell" + + ```bash + POWERTOOLS_LOG_DEDUPLICATION_DISABLED="1" pytest -o log_cli=1 + ``` !!! warning This feature should be used with care, as it explicitly disables our ability to filter propagated messages to the root logger (if configured). @@ -1069,40 +1075,40 @@ Here's an example where we persist `payment_id` not `request_id`. Note that `pay logger = Logger(service="payment") - def handler(event, context): - logger.append_keys(payment_id="123456789") + def handler(event, context): + logger.append_keys(payment_id="123456789") - try: - booking_id = book_flight() - logger.info("Flight booked successfully", extra={ "booking_id": booking_id}) - except BookingReservationError: - ... + try: + booking_id = book_flight() + logger.info("Flight booked successfully", extra={ "booking_id": booking_id}) + except BookingReservationError: + ... - logger.info("goodbye") + logger.info("goodbye") ``` === "Example CloudWatch Logs excerpt" - ```json hl_lines="8-9 18" - { - "level": "INFO", - "location": ":10", - "message": "Flight booked successfully", - "timestamp": "2021-01-12 14:09:10,859", - "service": "payment", - "sampling_rate": 0.0, - "payment_id": "123456789", - "booking_id": "75edbad0-0857-4fc9-b547-6180e2f7959b" - }, - { - "level": "INFO", - "location": ":14", - "message": "goodbye", - "timestamp": "2021-01-12 14:09:10,860", - "service": "payment", - "sampling_rate": 0.0, - "payment_id": "123456789" - } - ``` + ```json hl_lines="8-9 18" + { + "level": "INFO", + "location": ":10", + "message": "Flight booked successfully", + "timestamp": "2021-01-12 14:09:10,859", + "service": "payment", + "sampling_rate": 0.0, + "payment_id": "123456789", + "booking_id": "75edbad0-0857-4fc9-b547-6180e2f7959b" + }, + { + "level": "INFO", + "location": ":14", + "message": "goodbye", + "timestamp": "2021-01-12 14:09:10,860", + "service": "payment", + "sampling_rate": 0.0, + "payment_id": "123456789" + } + ``` **How do I aggregate and search Powertools logs across accounts?** diff --git a/docs/core/metrics.md b/docs/core/metrics.md index b556dce2a9e..d4bd9a0727e 100644 --- a/docs/core/metrics.md +++ b/docs/core/metrics.md @@ -26,7 +26,6 @@ If you're new to Amazon CloudWatch, there are two terminologies you must be awar
Metric terminology, visually explained
- ## Getting started Metric has two global settings that will be used across all metrics emitted: @@ -54,7 +53,6 @@ Setting | Description | Environment variable | Constructor parameter POWERTOOLS_METRICS_NAMESPACE: ServerlessAirline ``` - === "app.py" ```python hl_lines="4 6" @@ -62,7 +60,7 @@ Setting | Description | Environment variable | Constructor parameter from aws_lambda_powertools.metrics import MetricUnit metrics = Metrics() # Sets metric namespace and service via env var - # OR + # OR metrics = Metrics(namespace="ServerlessAirline", service="orders") # Sets metric namespace, and service as a metric dimension ``` @@ -80,9 +78,9 @@ You can create metrics using `add_metric`, and you can create dimensions for all metrics = Metrics(namespace="ExampleApplication", service="booking") - @metrics.log_metrics - def lambda_handler(evt, ctx): - metrics.add_metric(name="SuccessfulBooking", unit=MetricUnit.Count, value=1) + @metrics.log_metrics + def lambda_handler(evt, ctx): + metrics.add_metric(name="SuccessfulBooking", unit=MetricUnit.Count, value=1) ``` === "Metrics with custom dimensions" @@ -92,20 +90,20 @@ You can create metrics using `add_metric`, and you can create dimensions for all metrics = Metrics(namespace="ExampleApplication", service="booking") - @metrics.log_metrics - def lambda_handler(evt, ctx): - metrics.add_dimension(name="environment", value="prod") - metrics.add_metric(name="SuccessfulBooking", unit=MetricUnit.Count, value=1) + @metrics.log_metrics + def lambda_handler(evt, ctx): + metrics.add_dimension(name="environment", value="prod") + metrics.add_metric(name="SuccessfulBooking", unit=MetricUnit.Count, value=1) ``` !!! tip "Autocomplete Metric Units" - `MetricUnit` enum facilitate finding a supported metric unit by CloudWatch. Alternatively, you can pass the value as a string if you already know them e.g. "Count". + `MetricUnit` enum facilitate finding a supported metric unit by CloudWatch. Alternatively, you can pass the value as a string if you already know them e.g. "Count". !!! note "Metrics overflow" - CloudWatch EMF supports a max of 100 metrics per batch. Metrics utility will flush all metrics when adding the 100th metric. Subsequent metrics, e.g. 101th, will be aggregated into a new EMF object, for your convenience. + CloudWatch EMF supports a max of 100 metrics per batch. Metrics utility will flush all metrics when adding the 100th metric. Subsequent metrics, e.g. 101th, will be aggregated into a new EMF object, for your convenience. !!! warning "Do not create metrics or dimensions outside the handler" - Metrics or dimensions added in the global scope will only be added during cold start. Disregard if you that's the intended behaviour. + Metrics or dimensions added in the global scope will only be added during cold start. Disregard if you that's the intended behaviour. ### Adding default dimensions @@ -116,29 +114,29 @@ If you'd like to remove them at some point, you can use `clear_default_dimension === "set_default_dimensions method" ```python hl_lines="5" - from aws_lambda_powertools import Metrics - from aws_lambda_powertools.metrics import MetricUnit + from aws_lambda_powertools import Metrics + from aws_lambda_powertools.metrics import MetricUnit - metrics = Metrics(namespace="ExampleApplication", service="booking") - metrics.set_default_dimensions(environment="prod", another="one") + metrics = Metrics(namespace="ExampleApplication", service="booking") + metrics.set_default_dimensions(environment="prod", another="one") - @metrics.log_metrics - def lambda_handler(evt, ctx): - metrics.add_metric(name="SuccessfulBooking", unit=MetricUnit.Count, value=1) - ``` + @metrics.log_metrics + def lambda_handler(evt, ctx): + metrics.add_metric(name="SuccessfulBooking", unit=MetricUnit.Count, value=1) + ``` === "with log_metrics decorator" ```python hl_lines="5 7" - from aws_lambda_powertools import Metrics - from aws_lambda_powertools.metrics import MetricUnit + from aws_lambda_powertools import Metrics + from aws_lambda_powertools.metrics import MetricUnit - metrics = Metrics(namespace="ExampleApplication", service="booking") - DEFAULT_DIMENSIONS = {"environment": "prod", "another": "one"} + metrics = Metrics(namespace="ExampleApplication", service="booking") + DEFAULT_DIMENSIONS = {"environment": "prod", "another": "one"} - @metrics.log_metrics(default_dimensions=DEFAULT_DIMENSIONS) - def lambda_handler(evt, ctx): - metrics.add_metric(name="SuccessfulBooking", unit=MetricUnit.Count, value=1) - ``` + @metrics.log_metrics(default_dimensions=DEFAULT_DIMENSIONS) + def lambda_handler(evt, ctx): + metrics.add_metric(name="SuccessfulBooking", unit=MetricUnit.Count, value=1) + ``` ### Flushing metrics @@ -161,37 +159,37 @@ This decorator also **validates**, **serializes**, and **flushes** all your metr === "Example CloudWatch Logs excerpt" ```json hl_lines="2 7 10 15 22" - { - "BookingConfirmation": 1.0, - "_aws": { - "Timestamp": 1592234975665, - "CloudWatchMetrics": [ - { - "Namespace": "ExampleApplication", - "Dimensions": [ - [ - "service" - ] - ], - "Metrics": [ - { - "Name": "BookingConfirmation", - "Unit": "Count" - } - ] - } - ] - }, - "service": "ExampleService" - } + { + "BookingConfirmation": 1.0, + "_aws": { + "Timestamp": 1592234975665, + "CloudWatchMetrics": [ + { + "Namespace": "ExampleApplication", + "Dimensions": [ + [ + "service" + ] + ], + "Metrics": [ + { + "Name": "BookingConfirmation", + "Unit": "Count" + } + ] + } + ] + }, + "service": "ExampleService" + } ``` !!! tip "Metric validation" - If metrics are provided, and any of the following criteria are not met, **`SchemaValidationError`** exception will be raised: + If metrics are provided, and any of the following criteria are not met, **`SchemaValidationError`** exception will be raised: - * Maximum of 9 dimensions - * Namespace is set, and no more than one - * Metric units must be [supported by CloudWatch](https://docs.aws.amazon.com/AmazonCloudWatch/latest/APIReference/API_MetricDatum.html) + * Maximum of 9 dimensions + * Namespace is set, and no more than one + * Metric units must be [supported by CloudWatch](https://docs.aws.amazon.com/AmazonCloudWatch/latest/APIReference/API_MetricDatum.html) #### Raising SchemaValidationError on empty metrics @@ -210,7 +208,7 @@ If you want to ensure that at least one metric is emitted, you can pass `raise_o ``` !!! tip "Suppressing warning messages on empty metrics" - If you expect your function to execute without publishing metrics every time, you can suppress the warning with **`warnings.filterwarnings("ignore", "No metrics to publish*")`**. + If you expect your function to execute without publishing metrics every time, you can suppress the warning with **`warnings.filterwarnings("ignore", "No metrics to publish*")`**. #### Nesting multiple middlewares @@ -222,7 +220,7 @@ When using multiple middlewares, use `log_metrics` as your **last decorator** wr from aws_lambda_powertools import Metrics, Tracer from aws_lambda_powertools.metrics import MetricUnit - tracer = Tracer(service="booking") + tracer = Tracer(service="booking") metrics = Metrics(namespace="ExampleApplication", service="booking") @metrics.log_metrics @@ -273,39 +271,39 @@ You can add high-cardinality data as part of your Metrics log with `add_metadata metrics = Metrics(namespace="ExampleApplication", service="booking") - @metrics.log_metrics - def lambda_handler(evt, ctx): - metrics.add_metric(name="SuccessfulBooking", unit=MetricUnit.Count, value=1) - metrics.add_metadata(key="booking_id", value="booking_uuid") + @metrics.log_metrics + def lambda_handler(evt, ctx): + metrics.add_metric(name="SuccessfulBooking", unit=MetricUnit.Count, value=1) + metrics.add_metadata(key="booking_id", value="booking_uuid") ``` === "Example CloudWatch Logs excerpt" ```json hl_lines="23" - { - "SuccessfulBooking": 1.0, - "_aws": { - "Timestamp": 1592234975665, - "CloudWatchMetrics": [ - { - "Namespace": "ExampleApplication", - "Dimensions": [ - [ - "service" - ] - ], - "Metrics": [ - { - "Name": "SuccessfulBooking", - "Unit": "Count" - } - ] - } - ] - }, - "service": "booking", - "booking_id": "booking_uuid" - } + { + "SuccessfulBooking": 1.0, + "_aws": { + "Timestamp": 1592234975665, + "CloudWatchMetrics": [ + { + "Namespace": "ExampleApplication", + "Dimensions": [ + [ + "service" + ] + ], + "Metrics": [ + { + "Name": "SuccessfulBooking", + "Unit": "Count" + } + ] + } + ] + }, + "service": "booking", + "booking_id": "booking_uuid" + } ``` ### Single metric with a different dimension @@ -324,10 +322,10 @@ CloudWatch EMF uses the same dimensions across all your metrics. Use `single_met from aws_lambda_powertools.metrics import MetricUnit - def lambda_handler(evt, ctx): - with single_metric(name="ColdStart", unit=MetricUnit.Count, value=1, namespace="ExampleApplication") as metric: - metric.add_dimension(name="function_context", value="$LATEST") - ... + def lambda_handler(evt, ctx): + with single_metric(name="ColdStart", unit=MetricUnit.Count, value=1, namespace="ExampleApplication") as metric: + metric.add_dimension(name="function_context", value="$LATEST") + ... ``` ### Flushing metrics manually @@ -346,11 +344,11 @@ If you prefer not to use `log_metrics` because you might want to encapsulate add metrics = Metrics(namespace="ExampleApplication", service="booking") - def lambda_handler(evt, ctx): - metrics.add_metric(name="ColdStart", unit=MetricUnit.Count, value=1) - your_metrics_object = metrics.serialize_metric_set() - metrics.clear_metrics() - print(json.dumps(your_metrics_object)) + def lambda_handler(evt, ctx): + metrics.add_metric(name="ColdStart", unit=MetricUnit.Count, value=1) + your_metrics_object = metrics.serialize_metric_set() + metrics.clear_metrics() + print(json.dumps(your_metrics_object)) ``` ## Testing your code @@ -359,9 +357,11 @@ If you prefer not to use `log_metrics` because you might want to encapsulate add Use `POWERTOOLS_METRICS_NAMESPACE` and `POWERTOOLS_SERVICE_NAME` env vars when unit testing your code to ensure metric namespace and dimension objects are created, and your code doesn't fail validation. -```bash -POWERTOOLS_SERVICE_NAME="Example" POWERTOOLS_METRICS_NAMESPACE="Application" python -m pytest -``` +=== "shell" + + ```bash + POWERTOOLS_SERVICE_NAME="Example" POWERTOOLS_METRICS_NAMESPACE="Application" python -m pytest + ``` If you prefer setting environment variable for specific tests, and are using Pytest, you can use [monkeypatch](https://docs.pytest.org/en/latest/monkeypatch.html) fixture: @@ -401,68 +401,68 @@ As metrics are logged to standard output, you can read standard output and asser === "Assert single EMF blob with pytest.py" - ```python hl_lines="6 9-10 23-34" - from aws_lambda_powertools import Metrics - from aws_lambda_powertools.metrics import MetricUnit - - import json - - def test_log_metrics(capsys): - # GIVEN Metrics is initialized - metrics = Metrics(namespace="ServerlessAirline") - - # WHEN we utilize log_metrics to serialize - # and flush all metrics at the end of a function execution - @metrics.log_metrics - def lambda_handler(evt, ctx): - metrics.add_metric(name="SuccessfulBooking", unit=MetricUnit.Count, value=1) - metrics.add_dimension(name="environment", value="prod") + ```python hl_lines="6 9-10 23-34" + from aws_lambda_powertools import Metrics + from aws_lambda_powertools.metrics import MetricUnit - lambda_handler({}, {}) - log = capsys.readouterr().out.strip() # remove any extra line - metrics_output = json.loads(log) # deserialize JSON str + import json - # THEN we should have no exceptions - # and a valid EMF object should be flushed correctly - assert "SuccessfulBooking" in log # basic string assertion in JSON str - assert "SuccessfulBooking" in metrics_output["_aws"]["CloudWatchMetrics"][0]["Metrics"][0]["Name"] - ``` + def test_log_metrics(capsys): + # GIVEN Metrics is initialized + metrics = Metrics(namespace="ServerlessAirline") + + # WHEN we utilize log_metrics to serialize + # and flush all metrics at the end of a function execution + @metrics.log_metrics + def lambda_handler(evt, ctx): + metrics.add_metric(name="SuccessfulBooking", unit=MetricUnit.Count, value=1) + metrics.add_dimension(name="environment", value="prod") + + lambda_handler({}, {}) + log = capsys.readouterr().out.strip() # remove any extra line + metrics_output = json.loads(log) # deserialize JSON str + + # THEN we should have no exceptions + # and a valid EMF object should be flushed correctly + assert "SuccessfulBooking" in log # basic string assertion in JSON str + assert "SuccessfulBooking" in metrics_output["_aws"]["CloudWatchMetrics"][0]["Metrics"][0]["Name"] + ``` === "Assert multiple EMF blobs with pytest" - ```python hl_lines="8-9 11 21-23 25 29-30 32" - from aws_lambda_powertools import Metrics - from aws_lambda_powertools.metrics import MetricUnit + ```python hl_lines="8-9 11 21-23 25 29-30 32" + from aws_lambda_powertools import Metrics + from aws_lambda_powertools.metrics import MetricUnit - from collections import namedtuple + from collections import namedtuple - import json + import json - def capture_metrics_output_multiple_emf_objects(capsys): - return [json.loads(line.strip()) for line in capsys.readouterr().out.split("\n") if line] + def capture_metrics_output_multiple_emf_objects(capsys): + return [json.loads(line.strip()) for line in capsys.readouterr().out.split("\n") if line] - def test_log_metrics(capsys): - # GIVEN Metrics is initialized - metrics = Metrics(namespace="ServerlessAirline") + def test_log_metrics(capsys): + # GIVEN Metrics is initialized + metrics = Metrics(namespace="ServerlessAirline") - # WHEN log_metrics is used with capture_cold_start_metric - @metrics.log_metrics(capture_cold_start_metric=True) - def lambda_handler(evt, ctx): - metrics.add_metric(name="SuccessfulBooking", unit=MetricUnit.Count, value=1) - metrics.add_dimension(name="environment", value="prod") + # WHEN log_metrics is used with capture_cold_start_metric + @metrics.log_metrics(capture_cold_start_metric=True) + def lambda_handler(evt, ctx): + metrics.add_metric(name="SuccessfulBooking", unit=MetricUnit.Count, value=1) + metrics.add_dimension(name="environment", value="prod") - # log_metrics uses function_name property from context to add as a dimension for cold start metric - LambdaContext = namedtuple("LambdaContext", "function_name") - lambda_handler({}, LambdaContext("example_fn") + # log_metrics uses function_name property from context to add as a dimension for cold start metric + LambdaContext = namedtuple("LambdaContext", "function_name") + lambda_handler({}, LambdaContext("example_fn") - cold_start_blob, custom_metrics_blob = capture_metrics_output_multiple_emf_objects(capsys) + cold_start_blob, custom_metrics_blob = capture_metrics_output_multiple_emf_objects(capsys) - # THEN ColdStart metric and function_name dimension should be logged - # in a separate EMF blob than the application metrics - assert cold_start_blob["ColdStart"] == [1.0] - assert cold_start_blob["function_name"] == "example_fn" + # THEN ColdStart metric and function_name dimension should be logged + # in a separate EMF blob than the application metrics + assert cold_start_blob["ColdStart"] == [1.0] + assert cold_start_blob["function_name"] == "example_fn" - assert "SuccessfulBooking" in custom_metrics_blob # as per previous example - ``` + assert "SuccessfulBooking" in custom_metrics_blob # as per previous example + ``` !!! tip "For more elaborate assertions and comparisons, check out [our functional testing for Metrics utility](https://github.com/awslabs/aws-lambda-powertools-python/blob/develop/tests/functional/test_metrics.py)" diff --git a/docs/core/tracer.md b/docs/core/tracer.md index c6f2baa59fd..e2e2df52e18 100644 --- a/docs/core/tracer.md +++ b/docs/core/tracer.md @@ -23,6 +23,7 @@ Before your use this utility, your AWS Lambda function [must have permissions](h > Example using AWS Serverless Application Model (SAM) === "template.yml" + ```yaml hl_lines="6 9" Resources: HelloWorldFunction: @@ -40,18 +41,19 @@ Before your use this utility, your AWS Lambda function [must have permissions](h You can quickly start by importing the `Tracer` class, initialize it outside the Lambda handler, and use `capture_lambda_handler` decorator. === "app.py" - ```python hl_lines="1 3 6" - from aws_lambda_powertools import Tracer - tracer = Tracer() # Sets service via env var - # OR tracer = Tracer(service="example") + ```python hl_lines="1 3 6" + from aws_lambda_powertools import Tracer + + tracer = Tracer() # Sets service via env var + # OR tracer = Tracer(service="example") - @tracer.capture_lambda_handler - def handler(event, context): - charge_id = event.get('charge_id') - payment = collect_payment(charge_id) - ... - ``` + @tracer.capture_lambda_handler + def handler(event, context): + charge_id = event.get('charge_id') + payment = collect_payment(charge_id) + ... + ``` When using this `capture_lambda_handler` decorator, Tracer performs these additional tasks to ease operations: @@ -65,7 +67,7 @@ When using this `capture_lambda_handler` decorator, Tracer performs these additi **Metadata** are key-values also associated with traces but not indexed by AWS X-Ray. You can use them to add additional context for an operation using any native object. === "Annotations" - You can add annotations using `put_annotation` method. + You can add annotations using `put_annotation` method. ```python hl_lines="7" from aws_lambda_powertools import Tracer @@ -77,7 +79,7 @@ When using this `capture_lambda_handler` decorator, Tracer performs these additi tracer.put_annotation(key="PaymentStatus", value="SUCCESS") ``` === "Metadata" - You can add metadata using `put_metadata` method. + You can add metadata using `put_metadata` method. ```python hl_lines="8" from aws_lambda_powertools import Tracer @@ -101,13 +103,13 @@ You can trace synchronous functions using the `capture_method` decorator. unintended consequences if there are side effects to recursively reading the returned value, for example if the decorated function response contains a file-like object or a `StreamingBody` for S3 objects. -```python hl_lines="7 13" -@tracer.capture_method -def collect_payment(charge_id): - ret = requests.post(PAYMENT_ENDPOINT) # logic - tracer.put_annotation("PAYMENT_STATUS", "SUCCESS") # custom annotation - return ret -``` + ```python hl_lines="7 13" + @tracer.capture_method + def collect_payment(charge_id): + ret = requests.post(PAYMENT_ENDPOINT) # logic + tracer.put_annotation("PAYMENT_STATUS", "SUCCESS") # custom annotation + return ret + ``` ### Asynchronous and generator functions @@ -116,7 +118,6 @@ def collect_payment(charge_id): You can trace asynchronous functions and generator functions (including context managers) using `capture_method`. - === "Async" ```python hl_lines="7" @@ -164,17 +165,18 @@ You can trace asynchronous functions and generator functions (including context The decorator will detect whether your function is asynchronous, a generator, or a context manager and adapt its behaviour accordingly. -```python -@tracer.capture_lambda_handler -def handler(evt, ctx): - asyncio.run(collect_payment()) +=== "app.py" - with collect_payment_ctxman as result: - do_something_with(result) + ```python + @tracer.capture_lambda_handler + def handler(evt, ctx): + asyncio.run(collect_payment()) - another_result = list(collect_payment_gen()) -``` + with collect_payment_ctxman as result: + do_something_with(result) + another_result = list(collect_payment_gen()) + ``` ## Advanced @@ -184,15 +186,17 @@ Tracer automatically patches all [supported libraries by X-Ray](https://docs.aws If you're looking to shave a few microseconds, or milliseconds depending on your function memory configuration, you can patch specific modules using `patch_modules` param: -```python hl_lines="7" -import boto3 -import requests +=== "app.py" -from aws_lambda_powertools import Tracer + ```python hl_lines="7" + import boto3 + import requests -modules_to_be_patched = ["boto3", "requests"] -tracer = Tracer(patch_modules=modules_to_be_patched) -``` + from aws_lambda_powertools import Tracer + + modules_to_be_patched = ["boto3", "requests"] + tracer = Tracer(patch_modules=modules_to_be_patched) + ``` ### Disabling response auto-capture @@ -202,32 +206,34 @@ Use **`capture_response=False`** parameter in both `capture_lambda_handler` and !!! info "This is commonly useful in three scenarios" - 1. You might **return sensitive** information you don't want it to be added to your traces - 2. You might manipulate **streaming objects that can be read only once**; this prevents subsequent calls from being empty - 3. You might return **more than 64K** of data _e.g., `message too long` error_ + 1. You might **return sensitive** information you don't want it to be added to your traces + 2. You might manipulate **streaming objects that can be read only once**; this prevents subsequent calls from being empty + 3. You might return **more than 64K** of data _e.g., `message too long` error_ === "sensitive_data_scenario.py" - ```python hl_lines="3 7" - from aws_lambda_powertools import Tracer - @tracer.capture_method(capture_response=False) - def fetch_sensitive_information(): - return "sensitive_information" + ```python hl_lines="3 7" + from aws_lambda_powertools import Tracer - @tracer.capture_lambda_handler(capture_response=False) - def handler(event, context): - sensitive_information = fetch_sensitive_information() - ``` + @tracer.capture_method(capture_response=False) + def fetch_sensitive_information(): + return "sensitive_information" + + @tracer.capture_lambda_handler(capture_response=False) + def handler(event, context): + sensitive_information = fetch_sensitive_information() + ``` === "streaming_object_scenario.py" - ```python hl_lines="3" - from aws_lambda_powertools import Tracer - @tracer.capture_method(capture_response=False) - def get_s3_object(bucket_name, object_key): - s3 = boto3.client("s3") - s3_object = get_object(Bucket=bucket_name, Key=object_key) - return s3_object - ``` + ```python hl_lines="3" + from aws_lambda_powertools import Tracer + + @tracer.capture_method(capture_response=False) + def get_s3_object(bucket_name, object_key): + s3 = boto3.client("s3") + s3_object = get_object(Bucket=bucket_name, Key=object_key) + return s3_object + ``` ### Disabling exception auto-capture @@ -237,16 +243,17 @@ Use **`capture_error=False`** parameter in both `capture_lambda_handler` and `ca !!! info "Commonly useful in one scenario" - 1. You might **return sensitive** information from exceptions, stack traces you might not control + 1. You might **return sensitive** information from exceptions, stack traces you might not control === "sensitive_data_exception.py" - ```python hl_lines="3 5" - from aws_lambda_powertools import Tracer - @tracer.capture_lambda_handler(capture_error=False) - def handler(event, context): - raise ValueError("some sensitive info in the stack trace...") - ``` + ```python hl_lines="3 5" + from aws_lambda_powertools import Tracer + + @tracer.capture_lambda_handler(capture_error=False) + def handler(event, context): + raise ValueError("some sensitive info in the stack trace...") + ``` ### Tracing aiohttp requests @@ -256,21 +263,22 @@ Use **`capture_error=False`** parameter in both `capture_lambda_handler` and `ca You can use `aiohttp_trace_config` function to create a valid [aiohttp trace_config object](https://docs.aiohttp.org/en/stable/tracing_reference.html). This is necessary since X-Ray utilizes aiohttp trace hooks to capture requests end-to-end. === "aiohttp_example.py" - ```python hl_lines="5 10" - import asyncio - import aiohttp - from aws_lambda_powertools import Tracer - from aws_lambda_powertools.tracing import aiohttp_trace_config + ```python hl_lines="5 10" + import asyncio + import aiohttp - tracer = Tracer() + from aws_lambda_powertools import Tracer + from aws_lambda_powertools.tracing import aiohttp_trace_config - async def aiohttp_task(): - async with aiohttp.ClientSession(trace_configs=[aiohttp_trace_config()]) as session: - async with session.get("https://httpbin.org/json") as resp: - resp = await resp.json() - return resp - ``` + tracer = Tracer() + + async def aiohttp_task(): + async with aiohttp.ClientSession(trace_configs=[aiohttp_trace_config()]) as session: + async with session.get("https://httpbin.org/json") as resp: + resp = await resp.json() + return resp + ``` ### Escape hatch mechanism @@ -279,17 +287,18 @@ You can use `tracer.provider` attribute to access all methods provided by AWS X- This is useful when you need a feature available in X-Ray that is not available in the Tracer utility, for example [thread-safe](https://github.com/aws/aws-xray-sdk-python/#user-content-trace-threadpoolexecutor), or [context managers](https://github.com/aws/aws-xray-sdk-python/#user-content-start-a-custom-segmentsubsegment). === "escape_hatch_context_manager_example.py" - ```python hl_lines="7" - from aws_lambda_powertools import Tracer - tracer = Tracer() + ```python hl_lines="7" + from aws_lambda_powertools import Tracer + + tracer = Tracer() - @tracer.capture_lambda_handler - def handler(event, context): - with tracer.provider.in_subsegment('## custom subsegment') as subsegment: - ret = some_work() - subsegment.put_metadata('response', ret) - ``` + @tracer.capture_lambda_handler + def handler(event, context): + with tracer.provider.in_subsegment('## custom subsegment') as subsegment: + ret = some_work() + subsegment.put_metadata('response', ret) + ``` ### Concurrent asynchronous functions @@ -299,26 +308,27 @@ This is useful when you need a feature available in X-Ray that is not available A safe workaround mechanism is to use `in_subsegment_async` available via Tracer escape hatch (`tracer.provider`). === "concurrent_async_workaround.py" - ```python hl_lines="6 7 12 15 17" - import asyncio - from aws_lambda_powertools import Tracer - tracer = Tracer() + ```python hl_lines="6 7 12 15 17" + import asyncio + + from aws_lambda_powertools import Tracer + tracer = Tracer() - async def another_async_task(): - async with tracer.provider.in_subsegment_async("## another_async_task") as subsegment: - subsegment.put_annotation(key="key", value="value") - subsegment.put_metadata(key="key", value="value", namespace="namespace") - ... + async def another_async_task(): + async with tracer.provider.in_subsegment_async("## another_async_task") as subsegment: + subsegment.put_annotation(key="key", value="value") + subsegment.put_metadata(key="key", value="value", namespace="namespace") + ... - async def another_async_task_2(): - ... + async def another_async_task_2(): + ... - @tracer.capture_method - async def collect_payment(charge_id): - asyncio.gather(another_async_task(), another_async_task_2()) - ... - ``` + @tracer.capture_method + async def collect_payment(charge_id): + asyncio.gather(another_async_task(), another_async_task_2()) + ... + ``` ### Reusing Tracer across your code @@ -330,29 +340,30 @@ Tracer keeps a copy of its configuration after the first initialization. This is This can result in the first Tracer config being inherited by new instances, and their modules not being patched. === "handler.py" - ```python hl_lines="2 4 9" - from aws_lambda_powertools import Tracer - from payment import collect_payment - tracer = Tracer(service="payment") + ```python hl_lines="2 4 9" + from aws_lambda_powertools import Tracer + from payment import collect_payment + + tracer = Tracer(service="payment") - @tracer.capture_lambda_handler - def handler(event, context): - charge_id = event.get('charge_id') - payment = collect_payment(charge_id) - ``` + @tracer.capture_lambda_handler + def handler(event, context): + charge_id = event.get('charge_id') + payment = collect_payment(charge_id) + ``` === "payment.py" - A new instance of Tracer will be created but will reuse the previous Tracer instance configuration, similar to a Singleton. + A new instance of Tracer will be created but will reuse the previous Tracer instance configuration, similar to a Singleton. - ```python hl_lines="3 5" - from aws_lambda_powertools import Tracer + ```python hl_lines="3 5" + from aws_lambda_powertools import Tracer - tracer = Tracer(service="payment") + tracer = Tracer(service="payment") - @tracer.capture_method + @tracer.capture_method def collect_payment(charge_id: str): ... - ``` + ``` ## Testing your code diff --git a/docs/index.md b/docs/index.md index bd9d7875ece..781a96e2eb3 100644 --- a/docs/index.md +++ b/docs/index.md @@ -6,7 +6,7 @@ description: AWS Lambda Powertools Python A suite of utilities for AWS Lambda functions to ease adopting best practices such as tracing, structured logging, custom metrics, and more. !!! tip "Looking for a quick read through how the core features are used?" - Check out [this detailed blog post](https://aws.amazon.com/blogs/opensource/simplifying-serverless-best-practices-with-lambda-powertools/) with a practical example. + Check out [this detailed blog post](https://aws.amazon.com/blogs/opensource/simplifying-serverless-best-practices-with-lambda-powertools/) with a practical example. ## Tenets @@ -28,9 +28,11 @@ Powertools is available in PyPi. You can use your favourite dependency managemen **Quick hello world example using SAM CLI** -```bash -sam init --location https://github.com/aws-samples/cookiecutter-aws-sam-python -``` +=== "shell" + + ```bash + sam init --location https://github.com/aws-samples/cookiecutter-aws-sam-python + ``` ### Lambda Layer @@ -44,62 +46,61 @@ Powertools is also available as a Lambda Layer, and it is distributed via the [A !!! warning **Layer-extras** does not support Python 3.6 runtime. This layer also includes all extra dependencies: `22.4MB zipped`, `~155MB unzipped`. - If using SAM, you can include this SAR App as part of your shared Layers stack, and lock to a specific semantic version. Once deployed, it'll be available across the account this is deployed to. === "SAM" - ```yaml hl_lines="5-6 12-13" - AwsLambdaPowertoolsPythonLayer: - Type: AWS::Serverless::Application - Properties: - Location: - ApplicationId: arn:aws:serverlessrepo:eu-west-1:057560766410:applications/aws-lambda-powertools-python-layer - SemanticVersion: 1.17.0 # change to latest semantic version available in SAR - - MyLambdaFunction: - Type: AWS::Serverless::Function - Properties: - Layers: - # fetch Layer ARN from SAR App stack output - - !GetAtt AwsLambdaPowertoolsPythonLayer.Outputs.LayerVersionArn - ``` + ```yaml hl_lines="5-6 12-13" + AwsLambdaPowertoolsPythonLayer: + Type: AWS::Serverless::Application + Properties: + Location: + ApplicationId: arn:aws:serverlessrepo:eu-west-1:057560766410:applications/aws-lambda-powertools-python-layer + SemanticVersion: 1.17.0 # change to latest semantic version available in SAR + + MyLambdaFunction: + Type: AWS::Serverless::Function + Properties: + Layers: + # fetch Layer ARN from SAR App stack output + - !GetAtt AwsLambdaPowertoolsPythonLayer.Outputs.LayerVersionArn + ``` === "Serverless framework" - ```yaml hl_lines="5 8 10-11" - functions: - main: - handler: lambda_function.lambda_handler - layers: - - !GetAtt AwsLambdaPowertoolsPythonLayer.Outputs.LayerVersionArn - - resources: - Transform: AWS::Serverless-2016-10-31 - Resources: - AwsLambdaPowertoolsPythonLayer: - Type: AWS::Serverless::Application - Properties: - Location: - ApplicationId: arn:aws:serverlessrepo:eu-west-1:057560766410:applications/aws-lambda-powertools-python-layer - # Find latest from github.com/awslabs/aws-lambda-powertools-python/releases - SemanticVersion: 1.17.0 - ``` + ```yaml hl_lines="5 8 10-11" + functions: + main: + handler: lambda_function.lambda_handler + layers: + - !GetAtt AwsLambdaPowertoolsPythonLayer.Outputs.LayerVersionArn + + resources: + Transform: AWS::Serverless-2016-10-31 + Resources: + AwsLambdaPowertoolsPythonLayer: + Type: AWS::Serverless::Application + Properties: + Location: + ApplicationId: arn:aws:serverlessrepo:eu-west-1:057560766410:applications/aws-lambda-powertools-python-layer + # Find latest from github.com/awslabs/aws-lambda-powertools-python/releases + SemanticVersion: 1.17.0 + ``` === "CDK" - ```python hl_lines="14 22-23 31" - from aws_cdk import core, aws_sam as sam, aws_lambda + ```python hl_lines="14 22-23 31" + from aws_cdk import core, aws_sam as sam, aws_lambda POWERTOOLS_BASE_NAME = 'AWSLambdaPowertools' # Find latest from github.com/awslabs/aws-lambda-powertools-python/releases POWERTOOLS_VER = '1.17.0' POWERTOOLS_ARN = 'arn:aws:serverlessrepo:eu-west-1:057560766410:applications/aws-lambda-powertools-python-layer' - class SampleApp(core.Construct): + class SampleApp(core.Construct): - def __init__(self, scope: core.Construct, id_: str) -> None: - super().__init__(scope, id_) + def __init__(self, scope: core.Construct, id_: str) -> None: + super().__init__(scope, id_) # Launches SAR App as CloudFormation nested stack and return Lambda Layer powertools_app = sam.CfnApplication(self, @@ -114,86 +115,88 @@ If using SAM, you can include this SAR App as part of your shared Layers stack, powertools_layer_version = aws_lambda.LayerVersion.from_layer_version_arn(self, f'{POWERTOOLS_BASE_NAME}', powertools_layer_arn) aws_lambda.Function(self, - 'sample-app-lambda', + 'sample-app-lambda', runtime=aws_lambda.Runtime.PYTHON_3_8, function_name='sample-lambda', code=aws_lambda.Code.asset('./src'), handler='app.handler', layers: [powertools_layer_version] ) - ``` + ``` ??? tip "Example of least-privileged IAM permissions to deploy Layer" - > Credits to [mwarkentin](https://github.com/mwarkentin) for providing the scoped down IAM permissions. - - The region and the account id for `CloudFormationTransform` and `GetCfnTemplate` are fixed. - - === "template.yml" - - ```yaml hl_lines="21-52" - AWSTemplateFormatVersion: "2010-09-09" - Resources: - PowertoolsLayerIamRole: - Type: "AWS::IAM::Role" - Properties: - AssumeRolePolicyDocument: - Version: "2012-10-17" - Statement: - - Effect: "Allow" - Principal: - Service: - - "cloudformation.amazonaws.com" - Action: - - "sts:AssumeRole" - Path: "/" - PowertoolsLayerIamPolicy: - Type: "AWS::IAM::Policy" - Properties: - PolicyName: PowertoolsLambdaLayerPolicy - PolicyDocument: - Version: "2012-10-17" - Statement: - - Sid: CloudFormationTransform - Effect: Allow - Action: cloudformation:CreateChangeSet - Resource: - - arn:aws:cloudformation:us-east-1:aws:transform/Serverless-2016-10-31 - - Sid: GetCfnTemplate - Effect: Allow - Action: - - serverlessrepo:CreateCloudFormationTemplate - - serverlessrepo:GetCloudFormationTemplate - Resource: - # this is arn of the powertools SAR app - - arn:aws:serverlessrepo:eu-west-1:057560766410:applications/aws-lambda-powertools-python-layer - - Sid: S3AccessLayer - Effect: Allow - Action: - - s3:GetObject - Resource: - # AWS publishes to an external S3 bucket locked down to your account ID - # The below example is us publishing lambda powertools - # Bucket: awsserverlessrepo-changesets-plntc6bfnfj - # Key: *****/arn:aws:serverlessrepo:eu-west-1:057560766410:applications-aws-lambda-powertools-python-layer-versions-1.10.2/aeeccf50-****-****-****-********* - - arn:aws:s3:::awsserverlessrepo-changesets-*/* - - Sid: GetLayerVersion - Effect: Allow - Action: - - lambda:PublishLayerVersion - - lambda:GetLayerVersion - Resource: - - !Sub arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:layer:aws-lambda-powertools-python-layer* - Roles: - - Ref: "PowertoolsLayerIamRole" - ``` + > Credits to [mwarkentin](https://github.com/mwarkentin) for providing the scoped down IAM permissions. + + The region and the account id for `CloudFormationTransform` and `GetCfnTemplate` are fixed. + + === "template.yml" + + ```yaml hl_lines="21-52" + AWSTemplateFormatVersion: "2010-09-09" + Resources: + PowertoolsLayerIamRole: + Type: "AWS::IAM::Role" + Properties: + AssumeRolePolicyDocument: + Version: "2012-10-17" + Statement: + - Effect: "Allow" + Principal: + Service: + - "cloudformation.amazonaws.com" + Action: + - "sts:AssumeRole" + Path: "/" + PowertoolsLayerIamPolicy: + Type: "AWS::IAM::Policy" + Properties: + PolicyName: PowertoolsLambdaLayerPolicy + PolicyDocument: + Version: "2012-10-17" + Statement: + - Sid: CloudFormationTransform + Effect: Allow + Action: cloudformation:CreateChangeSet + Resource: + - arn:aws:cloudformation:us-east-1:aws:transform/Serverless-2016-10-31 + - Sid: GetCfnTemplate + Effect: Allow + Action: + - serverlessrepo:CreateCloudFormationTemplate + - serverlessrepo:GetCloudFormationTemplate + Resource: + # this is arn of the powertools SAR app + - arn:aws:serverlessrepo:eu-west-1:057560766410:applications/aws-lambda-powertools-python-layer + - Sid: S3AccessLayer + Effect: Allow + Action: + - s3:GetObject + Resource: + # AWS publishes to an external S3 bucket locked down to your account ID + # The below example is us publishing lambda powertools + # Bucket: awsserverlessrepo-changesets-plntc6bfnfj + # Key: *****/arn:aws:serverlessrepo:eu-west-1:057560766410:applications-aws-lambda-powertools-python-layer-versions-1.10.2/aeeccf50-****-****-****-********* + - arn:aws:s3:::awsserverlessrepo-changesets-*/* + - Sid: GetLayerVersion + Effect: Allow + Action: + - lambda:PublishLayerVersion + - lambda:GetLayerVersion + Resource: + - !Sub arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:layer:aws-lambda-powertools-python-layer* + Roles: + - Ref: "PowertoolsLayerIamRole" + ``` You can fetch available versions via SAR API with: -```bash -aws serverlessrepo list-application-versions \ - --application-id arn:aws:serverlessrepo:eu-west-1:057560766410:applications/aws-lambda-powertools-python-layer -``` +=== "shell" + + ```bash + aws serverlessrepo list-application-versions \ + --application-id arn:aws:serverlessrepo:eu-west-1:057560766410:applications/aws-lambda-powertools-python-layer + ``` ## Features @@ -238,6 +241,7 @@ aws serverlessrepo list-application-versions \ As a best practice, AWS Lambda Powertools logging statements are suppressed. If necessary, you can enable debugging using `set_package_logger`: === "app.py" + ```python from aws_lambda_powertools.logging.logger import set_package_logger diff --git a/docs/utilities/batch.md b/docs/utilities/batch.md index 26006427a14..96770fb1849 100644 --- a/docs/utilities/batch.md +++ b/docs/utilities/batch.md @@ -34,22 +34,23 @@ Before your use this utility, your AWS Lambda function must have `sqs:DeleteMess > Example using AWS Serverless Application Model (SAM) === "template.yml" + ```yaml hl_lines="2-3 12-15" Resources: - MyQueue: - Type: AWS::SQS::Queue + MyQueue: + Type: AWS::SQS::Queue - HelloWorldFunction: + HelloWorldFunction: Type: AWS::Serverless::Function Properties: - Runtime: python3.8 - Environment: + Runtime: python3.8 + Environment: Variables: - POWERTOOLS_SERVICE_NAME: example - Policies: - - SQSPollerPolicy: - QueueName: - !GetAtt MyQueue.QueueName + POWERTOOLS_SERVICE_NAME: example + Policies: + - SQSPollerPolicy: + QueueName: + !GetAtt MyQueue.QueueName ``` ### Processing messages from SQS @@ -90,9 +91,9 @@ You need to create a function to handle each record from the batch - We call it ``` !!! tip - **Any non-exception/successful return from your record handler function** will instruct both decorator and context manager to queue up each individual message for deletion. + **Any non-exception/successful return from your record handler function** will instruct both decorator and context manager to queue up each individual message for deletion. - If the entire batch succeeds, we let Lambda to proceed in deleting the records from the queue for cost reasons. + If the entire batch succeeds, we let Lambda to proceed in deleting the records from the queue for cost reasons. ### Partial failure mechanics @@ -104,7 +105,7 @@ All records in the batch will be passed to this handler for processing, even if !!! warning You will not have accessed to the **processed messages** within the Lambda Handler. - All processing logic will and should be performed by the `record_handler` function. + All processing logic will and should be performed by the `record_handler` function. ## Advanced @@ -114,8 +115,8 @@ They have nearly the same behaviour when it comes to processing messages from th * **Entire batch has been successfully processed**, where your Lambda handler returned successfully, we will let SQS delete the batch to optimize your cost * **Entire Batch has been partially processed successfully**, where exceptions were raised within your `record handler`, we will: - - **1)** Delete successfully processed messages from the queue by directly calling `sqs:DeleteMessageBatch` - - **2)** Raise `SQSBatchProcessingError` to ensure failed messages return to your SQS queue + * **1)** Delete successfully processed messages from the queue by directly calling `sqs:DeleteMessageBatch` + * **2)** Raise `SQSBatchProcessingError` to ensure failed messages return to your SQS queue The only difference is that **PartialSQSProcessor** will give you access to processed messages if you need. @@ -192,7 +193,6 @@ the `sqs_batch_processor` decorator: return result ``` - ### Suppressing exceptions If you want to disable the default behavior where `SQSBatchProcessingError` is raised if there are any errors, you can pass the `suppress_exception` boolean argument. @@ -300,15 +300,15 @@ When using Sentry.io for error monitoring, you can override `failure_handler` to === "sentry_integration.py" - ```python hl_lines="4 7-8" - from typing import Tuple + ```python hl_lines="4 7-8" + from typing import Tuple - from aws_lambda_powertools.utilities.batch import PartialSQSProcessor - from sentry_sdk import capture_exception + from aws_lambda_powertools.utilities.batch import PartialSQSProcessor + from sentry_sdk import capture_exception - class SQSProcessor(PartialSQSProcessor): - def failure_handler(self, record: Event, exception: Tuple) -> Tuple: # type: ignore - capture_exception() # send exception to Sentry - logger.exception("got exception while processing SQS message") - return super().failure_handler(record, exception) # type: ignore - ``` + class SQSProcessor(PartialSQSProcessor): + def failure_handler(self, record: Event, exception: Tuple) -> Tuple: # type: ignore + capture_exception() # send exception to Sentry + logger.exception("got exception while processing SQS message") + return super().failure_handler(record, exception) # type: ignore + ``` diff --git a/docs/utilities/data_classes.md b/docs/utilities/data_classes.md index 3217c5364d3..c75f41b39ec 100644 --- a/docs/utilities/data_classes.md +++ b/docs/utilities/data_classes.md @@ -29,41 +29,42 @@ For example, if your Lambda function is being triggered by an API Gateway proxy === "app.py" -```python hl_lines="1 4" -from aws_lambda_powertools.utilities.data_classes import APIGatewayProxyEvent + ```python hl_lines="1 4" + from aws_lambda_powertools.utilities.data_classes import APIGatewayProxyEvent -def lambda_handler(event: dict, context): - event = APIGatewayProxyEvent(event) - if 'helloworld' in event.path and event.http_method == 'GET': - do_something_with(event.body, user) -``` + def lambda_handler(event: dict, context): + event = APIGatewayProxyEvent(event) + if 'helloworld' in event.path and event.http_method == 'GET': + do_something_with(event.body, user) + ``` Same example as above, but using the `event_source` decorator === "app.py" -```python hl_lines="1 3" -from aws_lambda_powertools.utilities.data_classes import event_source, APIGatewayProxyEvent + ```python hl_lines="1 3" + from aws_lambda_powertools.utilities.data_classes import event_source, APIGatewayProxyEvent -@event_source(data_class=APIGatewayProxyEvent) -def lambda_handler(event: APIGatewayProxyEvent, context): - if 'helloworld' in event.path and event.http_method == 'GET': - do_something_with(event.body, user) -``` + @event_source(data_class=APIGatewayProxyEvent) + def lambda_handler(event: APIGatewayProxyEvent, context): + if 'helloworld' in event.path and event.http_method == 'GET': + do_something_with(event.body, user) + ``` **Autocomplete with self-documented properties and methods** - ![Utilities Data Classes](../media/utilities_data_classes.png) - ## Supported event sources Event Source | Data_class ------------------------------------------------- | --------------------------------------------------------------------------------- +[API Gateway Authorizer](#api-gateway-authorizer) | `APIGatewayAuthorizerRequestEvent` +[API Gateway Authorizer V2](#api-gateway-authorizer-v2) | `APIGatewayAuthorizerEventV2` [API Gateway Proxy](#api-gateway-proxy) | `APIGatewayProxyEvent` [API Gateway Proxy V2](#api-gateway-proxy-v2) | `APIGatewayProxyEventV2` [Application Load Balancer](#application-load-balancer) | `ALBEvent` +[AppSync Authorizer](#appsync-authorizer) | `AppSyncAuthorizerEvent` [AppSync Resolver](#appsync-resolver) | `AppSyncResolverEvent` [CloudWatch Logs](#cloudwatch-logs) | `CloudWatchLogsEvent` [CodePipeline Job Event](#codepipeline-job) | `CodePipelineJobEvent` @@ -78,11 +79,140 @@ Event Source | Data_class [SNS](#sns) | `SNSEvent` [SQS](#sqs) | `SQSEvent` - !!! info The examples provided below are far from exhaustive - the data classes themselves are designed to provide a form of documentation inherently (via autocompletion, types and docstrings). +### API Gateway Authorizer + +> New in 1.20.0 + +It is used for [API Gateway Rest API Lambda Authorizer payload](https://docs.aws.amazon.com/apigateway/latest/developerguide/apigateway-use-lambda-authorizer.html){target="_blank"}. + +Use **`APIGatewayAuthorizerRequestEvent`** for type `REQUEST` and **`APIGatewayAuthorizerTokenEvent`** for type `TOKEN`. + +=== "app_type_request.py" + + This example uses the `APIGatewayAuthorizerResponse` to decline a given request if the user is not found. + + When the user is found, it includes the user details in the request context that will be available to the back-end, and returns a full access policy for admin users. + + ```python hl_lines="2-5 26-31 36-37 40 44 46" + from aws_lambda_powertools.utilities.data_classes import event_source + from aws_lambda_powertools.utilities.data_classes.api_gateway_authorizer_event import ( + APIGatewayAuthorizerRequestEvent, + APIGatewayAuthorizerResponse, + HttpVerb, + ) + from secrets import compare_digest + + + def get_user_by_token(token): + if compare_digest(token, "admin-foo"): + return {"isAdmin": True, "name": "Admin"} + elif compare_digest(token, "regular-foo"): + return {"name": "Joe"} + else: + return None + + + @event_source(data_class=APIGatewayAuthorizerRequestEvent) + def handler(event: APIGatewayAuthorizerRequestEvent, context): + user = get_user_by_token(event.get_header_value("Authorization")) + + # parse the `methodArn` as an `APIGatewayRouteArn` + arn = event.parsed_arn + # Create the response builder from parts of the `methodArn` + policy = APIGatewayAuthorizerResponse( + principal_id="user", + region=arn.region, + aws_account_id=arn.aws_account_id, + api_id=arn.api_id, + stage=arn.stage + ) + + if user is None: + # No user was found, so we return not authorized + policy.deny_all_routes() + return policy.asdict() + + # Found the user and setting the details in the context + policy.context = user + + # Conditional IAM Policy + if user.get("isAdmin", False): + policy.allow_all_routes() + else: + policy.allow_route(HttpVerb.GET, "/user-profile") + + return policy.asdict() + ``` +=== "app_type_token.py" + + ```python hl_lines="2-5 12-18 21 23-24" + from aws_lambda_powertools.utilities.data_classes import event_source + from aws_lambda_powertools.utilities.data_classes.api_gateway_authorizer_event import ( + APIGatewayAuthorizerTokenEvent, + APIGatewayAuthorizerResponse, + ) + + + @event_source(data_class=APIGatewayAuthorizerTokenEvent) + def handler(event: APIGatewayAuthorizerTokenEvent, context): + arn = event.parsed_arn + + policy = APIGatewayAuthorizerResponse( + principal_id="user", + region=arn.region, + aws_account_id=arn.aws_account_id, + api_id=arn.api_id, + stage=arn.stage + ) + + if event.authorization_token == "42": + policy.allow_all_routes() + else: + policy.deny_all_routes() + return policy.asdict() + ``` + +### API Gateway Authorizer V2 + +> New in 1.20.0 + +It is used for [API Gateway HTTP API Lambda Authorizer payload version 2](https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-lambda-authorizer.html){target="_blank"}. +See also [this blog post](https://aws.amazon.com/blogs/compute/introducing-iam-and-lambda-authorizers-for-amazon-api-gateway-http-apis/){target="_blank"} for more details. + +=== "app.py" + + This example looks up user details via `x-token` header. It uses `APIGatewayAuthorizerResponseV2` to return a deny policy when user is not found or authorized. + + ```python hl_lines="2-5 21 24" + from aws_lambda_powertools.utilities.data_classes import event_source + from aws_lambda_powertools.utilities.data_classes.api_gateway_authorizer_event import ( + APIGatewayAuthorizerEventV2, + APIGatewayAuthorizerResponseV2, + ) + from secrets import compare_digest + + + def get_user_by_token(token): + if compare_digest(token, "Foo"): + return {"name": "Foo"} + return None + + + @event_source(data_class=APIGatewayAuthorizerEventV2) + def handler(event: APIGatewayAuthorizerEventV2, context): + user = get_user_by_token(event.get_header_value("x-token")) + + if user is None: + # No user was found, so we return not authorized + return APIGatewayAuthorizerResponseV2().asdict() + + # Found the user and setting the details in the context + return APIGatewayAuthorizerResponseV2(authorize=True, context=user).asdict() + ``` ### API Gateway Proxy @@ -90,17 +220,17 @@ It is used for either API Gateway REST API or HTTP API using v1 proxy event. === "app.py" -```python -from aws_lambda_powertools.utilities.data_classes import event_source, APIGatewayProxyEvent - -@event_source(data_class=APIGatewayProxyEvent) -def lambda_handler(event: APIGatewayProxyEvent, context): - if "helloworld" in event.path and event.http_method == "GET": - request_context = event.request_context - identity = request_context.identity - user = identity.user - do_something_with(event.json_body, user) -``` + ```python + from aws_lambda_powertools.utilities.data_classes import event_source, APIGatewayProxyEvent + + @event_source(data_class=APIGatewayProxyEvent) + def lambda_handler(event: APIGatewayProxyEvent, context): + if "helloworld" in event.path and event.http_method == "GET": + request_context = event.request_context + identity = request_context.identity + user = identity.user + do_something_with(event.json_body, user) + ``` ### API Gateway Proxy V2 @@ -108,14 +238,14 @@ It is used for HTTP API using v2 proxy event. === "app.py" -```python -from aws_lambda_powertools.utilities.data_classes import event_source, APIGatewayProxyEventV2 + ```python + from aws_lambda_powertools.utilities.data_classes import event_source, APIGatewayProxyEventV2 -@event_source(data_class=APIGatewayProxyEventV2) -def lambda_handler(event: APIGatewayProxyEventV2, context): - if "helloworld" in event.path and event.http_method == "POST": - do_something_with(event.json_body, event.query_string_parameters) -``` + @event_source(data_class=APIGatewayProxyEventV2) + def lambda_handler(event: APIGatewayProxyEventV2, context): + if "helloworld" in event.path and event.http_method == "POST": + do_something_with(event.json_body, event.query_string_parameters) + ``` ### Application Load Balancer @@ -123,14 +253,62 @@ Is it used for Application load balancer event. === "app.py" -```python -from aws_lambda_powertools.utilities.data_classes import event_source, ALBEvent + ```python + from aws_lambda_powertools.utilities.data_classes import event_source, ALBEvent + + @event_source(data_class=ALBEvent) + def lambda_handler(event: ALBEvent, context): + if "helloworld" in event.path and event.http_method == "POST": + do_something_with(event.json_body, event.query_string_parameters) + ``` + +### AppSync Authorizer + +> New in 1.20.0 + +Used when building an [AWS_LAMBDA Authorization](https://docs.aws.amazon.com/appsync/latest/devguide/security-authz.html#aws-lambda-authorization){target="_blank"} with AppSync. +See blog post [Introducing Lambda authorization for AWS AppSync GraphQL APIs](https://aws.amazon.com/blogs/mobile/appsync-lambda-auth/){target="_blank"} +or read the Amplify documentation on using [AWS Lambda for authorization](https://docs.amplify.aws/lib/graphqlapi/authz/q/platform/js#aws-lambda){target="_blank"} with AppSync. + +In this example extract the `requestId` as the `correlation_id` for logging, used `@event_source` decorator and builds the AppSync authorizer using the `AppSyncAuthorizerResponse` helper. -@event_source(data_class=ALBEvent) -def lambda_handler(event: ALBEvent, context): - if "helloworld" in event.path and event.http_method == "POST": - do_something_with(event.json_body, event.query_string_parameters) -``` +=== "app.py" + + ```python + from typing import Dict + + from aws_lambda_powertools.logging import correlation_paths + from aws_lambda_powertools.logging.logger import Logger + from aws_lambda_powertools.utilities.data_classes.appsync_authorizer_event import ( + AppSyncAuthorizerEvent, + AppSyncAuthorizerResponse, + ) + from aws_lambda_powertools.utilities.data_classes.event_source import event_source + + logger = Logger() + + + def get_user_by_token(token: str): + """Look a user by token""" + ... + + + @logger.inject_lambda_context(correlation_id_path=correlation_paths.APPSYNC_AUTHORIZER) + @event_source(data_class=AppSyncAuthorizerEvent) + def lambda_handler(event: AppSyncAuthorizerEvent, context) -> Dict: + user = get_user_by_token(event.authorization_token) + + if not user: + # No user found, return not authorized + return AppSyncAuthorizerResponse().to_dict() + + return AppSyncAuthorizerResponse( + authorize=True, + resolver_context={"id": user.id}, + # Only allow admins to delete events + deny_fields=None if user.is_admin else ["Mutation.deleteEvent"], + ).asdict() + ``` ### AppSync Resolver @@ -178,6 +356,7 @@ In this example, we also use the new Logger `correlation_id` and built-in `corre raise ValueError(f"Unsupported field resolver: {event.field_name}") ``` + === "Example AppSync Event" ```json hl_lines="2-8 14 19 20" @@ -237,17 +416,17 @@ decompress and parse json data from the event. === "app.py" -```python -from aws_lambda_powertools.utilities.data_classes import event_source, CloudWatchLogsEvent -from aws_lambda_powertools.utilities.data_classes.cloud_watch_logs_event import CloudWatchLogsDecodedData - -@event_source(data_class=CloudWatchLogsEvent) -def lambda_handler(event: CloudWatchLogsEvent, context): - decompressed_log: CloudWatchLogsDecodedData = event.parse_logs_data - log_events = decompressed_log.log_events - for event in log_events: - do_something_with(event.timestamp, event.message) -``` + ```python + from aws_lambda_powertools.utilities.data_classes import event_source, CloudWatchLogsEvent + from aws_lambda_powertools.utilities.data_classes.cloud_watch_logs_event import CloudWatchLogsDecodedData + + @event_source(data_class=CloudWatchLogsEvent) + def lambda_handler(event: CloudWatchLogsEvent, context): + decompressed_log: CloudWatchLogsDecodedData = event.parse_logs_data + log_events = decompressed_log.log_events + for event in log_events: + do_something_with(event.timestamp, event.message) + ``` ### CodePipeline Job @@ -255,50 +434,50 @@ Data classes and utility functions to help create continuous delivery pipelines === "app.py" -```python -from aws_lambda_powertools import Logger -from aws_lambda_powertools.utilities.data_classes import event_source, CodePipelineJobEvent - -logger = Logger() - -@event_source(data_class=CodePipelineJobEvent) -def lambda_handler(event, context): - """The Lambda function handler - - If a continuing job then checks the CloudFormation stack status - and updates the job accordingly. - - If a new job then kick of an update or creation of the target - CloudFormation stack. - """ - - # Extract the Job ID - job_id = event.get_id + ```python + from aws_lambda_powertools import Logger + from aws_lambda_powertools.utilities.data_classes import event_source, CodePipelineJobEvent - # Extract the params - params: dict = event.decoded_user_parameters - stack = params["stack"] - artifact_name = params["artifact"] - template_file = params["file"] + logger = Logger() - try: - if event.data.continuation_token: - # If we're continuing then the create/update has already been triggered - # we just need to check if it has finished. - check_stack_update_status(job_id, stack) - else: - template = event.get_artifact(artifact_name, template_file) - # Kick off a stack update or create - start_update_or_create(job_id, stack, template) - except Exception as e: - # If any other exceptions which we didn't expect are raised - # then fail the job and log the exception message. - logger.exception("Function failed due to exception.") - put_job_failure(job_id, "Function exception: " + str(e)) - - logger.debug("Function complete.") - return "Complete." -``` + @event_source(data_class=CodePipelineJobEvent) + def lambda_handler(event, context): + """The Lambda function handler + + If a continuing job then checks the CloudFormation stack status + and updates the job accordingly. + + If a new job then kick of an update or creation of the target + CloudFormation stack. + """ + + # Extract the Job ID + job_id = event.get_id + + # Extract the params + params: dict = event.decoded_user_parameters + stack = params["stack"] + artifact_name = params["artifact"] + template_file = params["file"] + + try: + if event.data.continuation_token: + # If we're continuing then the create/update has already been triggered + # we just need to check if it has finished. + check_stack_update_status(job_id, stack) + else: + template = event.get_artifact(artifact_name, template_file) + # Kick off a stack update or create + start_update_or_create(job_id, stack, template) + except Exception as e: + # If any other exceptions which we didn't expect are raised + # then fail the job and log the exception message. + logger.exception("Function failed due to exception.") + put_job_failure(job_id, "Function exception: " + str(e)) + + logger.debug("Function complete.") + return "Complete." + ``` ### Cognito User Pool @@ -322,15 +501,15 @@ Verify Auth Challenge | `data_classes.cognito_user_pool_event.VerifyAuthChalleng === "app.py" -```python -from aws_lambda_powertools.utilities.data_classes.cognito_user_pool_event import PostConfirmationTriggerEvent + ```python + from aws_lambda_powertools.utilities.data_classes.cognito_user_pool_event import PostConfirmationTriggerEvent -def lambda_handler(event, context): - event: PostConfirmationTriggerEvent = PostConfirmationTriggerEvent(event) + def lambda_handler(event, context): + event: PostConfirmationTriggerEvent = PostConfirmationTriggerEvent(event) - user_attributes = event.request.user_attributes - do_something_with(user_attributes) -``` + user_attributes = event.request.user_attributes + do_something_with(user_attributes) + ``` #### Define Auth Challenge Example @@ -495,18 +674,18 @@ This example is based on the AWS Cognito docs for [Create Auth Challenge Lambda === "app.py" -```python -from aws_lambda_powertools.utilities.data_classes import event_source -from aws_lambda_powertools.utilities.data_classes.cognito_user_pool_event import CreateAuthChallengeTriggerEvent - -@event_source(data_class=CreateAuthChallengeTriggerEvent) -def handler(event: CreateAuthChallengeTriggerEvent, context) -> dict: - if event.request.challenge_name == "CUSTOM_CHALLENGE": - event.response.public_challenge_parameters = {"captchaUrl": "url/123.jpg"} - event.response.private_challenge_parameters = {"answer": "5"} - event.response.challenge_metadata = "CAPTCHA_CHALLENGE" - return event.raw_event -``` + ```python + from aws_lambda_powertools.utilities.data_classes import event_source + from aws_lambda_powertools.utilities.data_classes.cognito_user_pool_event import CreateAuthChallengeTriggerEvent + + @event_source(data_class=CreateAuthChallengeTriggerEvent) + def handler(event: CreateAuthChallengeTriggerEvent, context) -> dict: + if event.request.challenge_name == "CUSTOM_CHALLENGE": + event.response.public_challenge_parameters = {"captchaUrl": "url/123.jpg"} + event.response.private_challenge_parameters = {"answer": "5"} + event.response.challenge_metadata = "CAPTCHA_CHALLENGE" + return event.raw_event + ``` #### Verify Auth Challenge Response Example @@ -514,17 +693,17 @@ This example is based on the AWS Cognito docs for [Verify Auth Challenge Respons === "app.py" -```python -from aws_lambda_powertools.utilities.data_classes import event_source -from aws_lambda_powertools.utilities.data_classes.cognito_user_pool_event import VerifyAuthChallengeResponseTriggerEvent - -@event_source(data_class=VerifyAuthChallengeResponseTriggerEvent) -def handler(event: VerifyAuthChallengeResponseTriggerEvent, context) -> dict: - event.response.answer_correct = ( - event.request.private_challenge_parameters.get("answer") == event.request.challenge_answer - ) - return event.raw_event -``` + ```python + from aws_lambda_powertools.utilities.data_classes import event_source + from aws_lambda_powertools.utilities.data_classes.cognito_user_pool_event import VerifyAuthChallengeResponseTriggerEvent + + @event_source(data_class=VerifyAuthChallengeResponseTriggerEvent) + def handler(event: VerifyAuthChallengeResponseTriggerEvent, context) -> dict: + event.response.answer_correct = ( + event.request.private_challenge_parameters.get("answer") == event.request.challenge_answer + ) + return event.raw_event + ``` ### Connect Contact Flow @@ -532,21 +711,21 @@ def handler(event: VerifyAuthChallengeResponseTriggerEvent, context) -> dict: === "app.py" -```python -from aws_lambda_powertools.utilities.data_classes.connect_contact_flow_event import ( - ConnectContactFlowChannel, - ConnectContactFlowEndpointType, - ConnectContactFlowEvent, - ConnectContactFlowInitiationMethod, -) - -def lambda_handler(event, context): - event: ConnectContactFlowEvent = ConnectContactFlowEvent(event) - assert event.contact_data.attributes == {"Language": "en-US"} - assert event.contact_data.channel == ConnectContactFlowChannel.VOICE - assert event.contact_data.customer_endpoint.endpoint_type == ConnectContactFlowEndpointType.TELEPHONE_NUMBER - assert event.contact_data.initiation_method == ConnectContactFlowInitiationMethod.API -``` + ```python + from aws_lambda_powertools.utilities.data_classes.connect_contact_flow_event import ( + ConnectContactFlowChannel, + ConnectContactFlowEndpointType, + ConnectContactFlowEvent, + ConnectContactFlowInitiationMethod, + ) + + def lambda_handler(event, context): + event: ConnectContactFlowEvent = ConnectContactFlowEvent(event) + assert event.contact_data.attributes == {"Language": "en-US"} + assert event.contact_data.channel == ConnectContactFlowChannel.VOICE + assert event.contact_data.customer_endpoint.endpoint_type == ConnectContactFlowEndpointType.TELEPHONE_NUMBER + assert event.contact_data.initiation_method == ConnectContactFlowInitiationMethod.API + ``` ### DynamoDB Streams @@ -556,55 +735,55 @@ attributes values (`AttributeValue`), as well as enums for stream view type (`St === "app.py" - ```python - from aws_lambda_powertools.utilities.data_classes.dynamo_db_stream_event import ( - DynamoDBStreamEvent, - DynamoDBRecordEventName - ) + ```python + from aws_lambda_powertools.utilities.data_classes.dynamo_db_stream_event import ( + DynamoDBStreamEvent, + DynamoDBRecordEventName + ) - def lambda_handler(event, context): - event: DynamoDBStreamEvent = DynamoDBStreamEvent(event) + def lambda_handler(event, context): + event: DynamoDBStreamEvent = DynamoDBStreamEvent(event) - # Multiple records can be delivered in a single event - for record in event.records: - if record.event_name == DynamoDBRecordEventName.MODIFY: - do_something_with(record.dynamodb.new_image) - do_something_with(record.dynamodb.old_image) - ``` + # Multiple records can be delivered in a single event + for record in event.records: + if record.event_name == DynamoDBRecordEventName.MODIFY: + do_something_with(record.dynamodb.new_image) + do_something_with(record.dynamodb.old_image) + ``` === "multiple_records_types.py" - ```python - from aws_lambda_powertools.utilities.data_classes import event_source, DynamoDBStreamEvent - from aws_lambda_powertools.utilities.data_classes.dynamo_db_stream_event import AttributeValueType, AttributeValue - from aws_lambda_powertools.utilities.typing import LambdaContext - - - @event_source(data_class=DynamoDBStreamEvent) - def lambda_handler(event: DynamoDBStreamEvent, context: LambdaContext): - for record in event.records: - key: AttributeValue = record.dynamodb.keys["id"] - if key == AttributeValueType.Number: - # {"N": "123.45"} => "123.45" - assert key.get_value == key.n_value - print(key.get_value) - elif key == AttributeValueType.Map: - assert key.get_value == key.map_value - print(key.get_value) - ``` + ```python + from aws_lambda_powertools.utilities.data_classes import event_source, DynamoDBStreamEvent + from aws_lambda_powertools.utilities.data_classes.dynamo_db_stream_event import AttributeValueType, AttributeValue + from aws_lambda_powertools.utilities.typing import LambdaContext + + + @event_source(data_class=DynamoDBStreamEvent) + def lambda_handler(event: DynamoDBStreamEvent, context: LambdaContext): + for record in event.records: + key: AttributeValue = record.dynamodb.keys["id"] + if key == AttributeValueType.Number: + # {"N": "123.45"} => "123.45" + assert key.get_value == key.n_value + print(key.get_value) + elif key == AttributeValueType.Map: + assert key.get_value == key.map_value + print(key.get_value) + ``` ### EventBridge === "app.py" -```python -from aws_lambda_powertools.utilities.data_classes import event_source, EventBridgeEvent + ```python + from aws_lambda_powertools.utilities.data_classes import event_source, EventBridgeEvent -@event_source(data_class=EventBridgeEvent) -def lambda_handler(event: EventBridgeEvent, context): - do_something_with(event.detail) + @event_source(data_class=EventBridgeEvent) + def lambda_handler(event: EventBridgeEvent, context): + do_something_with(event.detail) -``` + ``` ### Kinesis streams @@ -613,40 +792,40 @@ or plain text, depending on the original payload. === "app.py" -```python -from aws_lambda_powertools.utilities.data_classes import event_source, KinesisStreamEvent + ```python + from aws_lambda_powertools.utilities.data_classes import event_source, KinesisStreamEvent -@event_source(data_class=KinesisStreamEvent) -def lambda_handler(event: KinesisStreamEvent, context): - kinesis_record = next(event.records).kinesis + @event_source(data_class=KinesisStreamEvent) + def lambda_handler(event: KinesisStreamEvent, context): + kinesis_record = next(event.records).kinesis - # if data was delivered as text - data = kinesis_record.data_as_text() + # if data was delivered as text + data = kinesis_record.data_as_text() - # if data was delivered as json - data = kinesis_record.data_as_json() + # if data was delivered as json + data = kinesis_record.data_as_json() - do_something_with(data) -``` + do_something_with(data) + ``` ### S3 === "app.py" -```python -from urllib.parse import unquote_plus -from aws_lambda_powertools.utilities.data_classes import event_source, S3Event + ```python + from urllib.parse import unquote_plus + from aws_lambda_powertools.utilities.data_classes import event_source, S3Event -@event_source(data_class=S3Event) -def lambda_handler(event: S3Event, context): - bucket_name = event.bucket_name + @event_source(data_class=S3Event) + def lambda_handler(event: S3Event, context): + bucket_name = event.bucket_name - # Multiple records can be delivered in a single event - for record in event.records: - object_key = unquote_plus(record.s3.get_object.key) + # Multiple records can be delivered in a single event + for record in event.records: + object_key = unquote_plus(record.s3.get_object.key) - do_something_with(f"{bucket_name}/{object_key}") -``` + do_something_with(f"{bucket_name}/{object_key}") + ``` ### S3 Object Lambda @@ -654,81 +833,81 @@ This example is based on the AWS Blog post [Introducing Amazon S3 Object Lambda === "app.py" -```python hl_lines="5-6 12 14" -import boto3 -import requests + ```python hl_lines="5-6 12 14" + import boto3 + import requests -from aws_lambda_powertools import Logger -from aws_lambda_powertools.logging.correlation_paths import S3_OBJECT_LAMBDA -from aws_lambda_powertools.utilities.data_classes.s3_object_event import S3ObjectLambdaEvent + from aws_lambda_powertools import Logger + from aws_lambda_powertools.logging.correlation_paths import S3_OBJECT_LAMBDA + from aws_lambda_powertools.utilities.data_classes.s3_object_event import S3ObjectLambdaEvent -logger = Logger() -session = boto3.Session() -s3 = session.client("s3") + logger = Logger() + session = boto3.Session() + s3 = session.client("s3") -@logger.inject_lambda_context(correlation_id_path=S3_OBJECT_LAMBDA, log_event=True) -def lambda_handler(event, context): - event = S3ObjectLambdaEvent(event) + @logger.inject_lambda_context(correlation_id_path=S3_OBJECT_LAMBDA, log_event=True) + def lambda_handler(event, context): + event = S3ObjectLambdaEvent(event) - # Get object from S3 - response = requests.get(event.input_s3_url) - original_object = response.content.decode("utf-8") + # Get object from S3 + response = requests.get(event.input_s3_url) + original_object = response.content.decode("utf-8") - # Make changes to the object about to be returned - transformed_object = original_object.upper() + # Make changes to the object about to be returned + transformed_object = original_object.upper() - # Write object back to S3 Object Lambda - s3.write_get_object_response( - Body=transformed_object, RequestRoute=event.request_route, RequestToken=event.request_token - ) + # Write object back to S3 Object Lambda + s3.write_get_object_response( + Body=transformed_object, RequestRoute=event.request_route, RequestToken=event.request_token + ) - return {"status_code": 200} -``` + return {"status_code": 200} + ``` ### SES === "app.py" -```python -from aws_lambda_powertools.utilities.data_classes import event_source, SESEvent + ```python + from aws_lambda_powertools.utilities.data_classes import event_source, SESEvent -@event_source(data_class=SESEvent) -def lambda_handler(event: SESEvent, context): - # Multiple records can be delivered in a single event - for record in event.records: - mail = record.ses.mail - common_headers = mail.common_headers + @event_source(data_class=SESEvent) + def lambda_handler(event: SESEvent, context): + # Multiple records can be delivered in a single event + for record in event.records: + mail = record.ses.mail + common_headers = mail.common_headers - do_something_with(common_headers.to, common_headers.subject) -``` + do_something_with(common_headers.to, common_headers.subject) + ``` ### SNS === "app.py" -```python -from aws_lambda_powertools.utilities.data_classes import event_source, SNSEvent + ```python + from aws_lambda_powertools.utilities.data_classes import event_source, SNSEvent -@event_source(data_class=SNSEvent) -def lambda_handler(event: SNSEvent, context): - # Multiple records can be delivered in a single event - for record in event.records: - message = record.sns.message - subject = record.sns.subject + @event_source(data_class=SNSEvent) + def lambda_handler(event: SNSEvent, context): + # Multiple records can be delivered in a single event + for record in event.records: + message = record.sns.message + subject = record.sns.subject - do_something_with(subject, message) -``` + do_something_with(subject, message) + ``` ### SQS === "app.py" -```python -from aws_lambda_powertools.utilities.data_classes import event_source, SQSEvent + ```python + from aws_lambda_powertools.utilities.data_classes import event_source, SQSEvent -@event_source(data_class=SQSEvent) -def lambda_handler(event: SQSEvent, context): - # Multiple records can be delivered in a single event - for record in event.records: - do_something_with(record.body) -``` + @event_source(data_class=SQSEvent) + def lambda_handler(event: SQSEvent, context): + # Multiple records can be delivered in a single event + for record in event.records: + do_something_with(record.body) + ``` diff --git a/docs/utilities/feature_flags.md b/docs/utilities/feature_flags.md index 556cf9f4925..d22f9c03296 100644 --- a/docs/utilities/feature_flags.md +++ b/docs/utilities/feature_flags.md @@ -110,67 +110,67 @@ The following sample infrastructure will be used throughout this documentation: === "CDK" - ```python hl_lines="11-22 24 29 35 42 50" - import json - - import aws_cdk.aws_appconfig as appconfig - from aws_cdk import core - - - class SampleFeatureFlagStore(core.Construct): - def __init__(self, scope: core.Construct, id_: str) -> None: - super().__init__(scope, id_) - - features_config = { - "premium_features": { - "default": False, - "rules": { - "customer tier equals premium": { - "when_match": True, - "conditions": [{"action": "EQUALS", "key": "tier", "value": "premium"}], - } - }, - }, - "ten_percent_off_campaign": {"default": True}, - } - - self.config_app = appconfig.CfnApplication( - self, - id="app", - name="product-catalogue", - ) - self.config_env = appconfig.CfnEnvironment( - self, - id="env", - application_id=self.config_app.ref, - name="dev-env", - ) - self.config_profile = appconfig.CfnConfigurationProfile( - self, - id="profile", - application_id=self.config_app.ref, - location_uri="hosted", - name="features", - ) - self.hosted_cfg_version = appconfig.CfnHostedConfigurationVersion( - self, - "version", - application_id=self.config_app.ref, - configuration_profile_id=self.config_profile.ref, - content=json.dumps(features_config), - content_type="application/json", - ) - self.app_config_deployment = appconfig.CfnDeployment( - self, - id="deploy", - application_id=self.config_app.ref, - configuration_profile_id=self.config_profile.ref, - configuration_version=self.hosted_cfg_version.ref, - deployment_strategy_id="AppConfig.AllAtOnce", - environment_id=self.config_env.ref, - ) - - ``` + ```python hl_lines="11-22 24 29 35 42 50" + import json + + import aws_cdk.aws_appconfig as appconfig + from aws_cdk import core + + + class SampleFeatureFlagStore(core.Construct): + def __init__(self, scope: core.Construct, id_: str) -> None: + super().__init__(scope, id_) + + features_config = { + "premium_features": { + "default": False, + "rules": { + "customer tier equals premium": { + "when_match": True, + "conditions": [{"action": "EQUALS", "key": "tier", "value": "premium"}], + } + }, + }, + "ten_percent_off_campaign": {"default": True}, + } + + self.config_app = appconfig.CfnApplication( + self, + id="app", + name="product-catalogue", + ) + self.config_env = appconfig.CfnEnvironment( + self, + id="env", + application_id=self.config_app.ref, + name="dev-env", + ) + self.config_profile = appconfig.CfnConfigurationProfile( + self, + id="profile", + application_id=self.config_app.ref, + location_uri="hosted", + name="features", + ) + self.hosted_cfg_version = appconfig.CfnHostedConfigurationVersion( + self, + "version", + application_id=self.config_app.ref, + configuration_profile_id=self.config_profile.ref, + content=json.dumps(features_config), + content_type="application/json", + ) + self.app_config_deployment = appconfig.CfnDeployment( + self, + id="deploy", + application_id=self.config_app.ref, + configuration_profile_id=self.config_profile.ref, + configuration_version=self.hosted_cfg_version.ref, + deployment_strategy_id="AppConfig.AllAtOnce", + environment_id=self.config_env.ref, + ) + + ``` ### Evaluating a single feature flag @@ -184,7 +184,7 @@ The `evaluate` method supports two optional parameters: === "app.py" ```python hl_lines="3 9 13 17-19" - from aws_lambda_powertools.utilities.feature_flags import FeatureFlags, AppConfigStore + from aws_lambda_powertools.utilities.feature_flags import FeatureFlags, AppConfigStore app_config = AppConfigStore( environment="dev", @@ -194,50 +194,50 @@ The `evaluate` method supports two optional parameters: feature_flags = FeatureFlags(store=app_config) - def lambda_handler(event, context): - # Get customer's tier from incoming request - ctx = { "tier": event.get("tier", "standard") } + def lambda_handler(event, context): + # Get customer's tier from incoming request + ctx = { "tier": event.get("tier", "standard") } - # Evaluate whether customer's tier has access to premium features - # based on `has_premium_features` rules - has_premium_features: bool = feature_flags.evaluate(name="premium_features", + # Evaluate whether customer's tier has access to premium features + # based on `has_premium_features` rules + has_premium_features: bool = feature_flags.evaluate(name="premium_features", context=ctx, default=False) - if has_premium_features: - # enable premium features - ... + if has_premium_features: + # enable premium features + ... ``` === "event.json" - ```json hl_lines="3" - { - "username": "lessa", - "tier": "premium", - "basked_id": "random_id" - } - ``` + ```json hl_lines="3" + { + "username": "lessa", + "tier": "premium", + "basked_id": "random_id" + } + ``` === "features.json" ```json hl_lines="2 6 9-11" - { - "premium_features": { - "default": false, - "rules": { - "customer tier equals premium": { - "when_match": true, - "conditions": [ - { - "action": "EQUALS", - "key": "tier", - "value": "premium" - } - ] - } - } - }, - "ten_percent_off_campaign": { - "default": false - } + { + "premium_features": { + "default": false, + "rules": { + "customer tier equals premium": { + "when_match": true, + "conditions": [ + { + "action": "EQUALS", + "key": "tier", + "value": "premium" + } + ] + } + } + }, + "ten_percent_off_campaign": { + "default": false + } } ``` @@ -250,7 +250,7 @@ In this case, we could omit the `context` parameter and simply evaluate whether === "app.py" ```python hl_lines="12-13" - from aws_lambda_powertools.utilities.feature_flags import FeatureFlags, AppConfigStore + from aws_lambda_powertools.utilities.feature_flags import FeatureFlags, AppConfigStore app_config = AppConfigStore( environment="dev", @@ -260,22 +260,22 @@ In this case, we could omit the `context` parameter and simply evaluate whether feature_flags = FeatureFlags(store=app_config) - def lambda_handler(event, context): - apply_discount: bool = feature_flags.evaluate(name="ten_percent_off_campaign", - default=False) + def lambda_handler(event, context): + apply_discount: bool = feature_flags.evaluate(name="ten_percent_off_campaign", + default=False) - if apply_discount: - # apply 10% discount to product - ... + if apply_discount: + # apply 10% discount to product + ... ``` === "features.json" ```json hl_lines="2-3" - { - "ten_percent_off_campaign": { - "default": false - } + { + "ten_percent_off_campaign": { + "default": false + } } ``` @@ -288,10 +288,10 @@ You can use `get_enabled_features` method for scenarios where you need a list of === "app.py" ```python hl_lines="17-20 23" - from aws_lambda_powertools.event_handler.api_gateway import ApiGatewayResolver - from aws_lambda_powertools.utilities.feature_flags import FeatureFlags, AppConfigStore + from aws_lambda_powertools.event_handler.api_gateway import ApiGatewayResolver + from aws_lambda_powertools.utilities.feature_flags import FeatureFlags, AppConfigStore - app = ApiGatewayResolver() + app = ApiGatewayResolver() app_config = AppConfigStore( environment="dev", @@ -301,85 +301,82 @@ You can use `get_enabled_features` method for scenarios where you need a list of feature_flags = FeatureFlags(store=app_config) + @app.get("/products") + def list_products(): + ctx = { + **app.current_event.headers, + **app.current_event.json_body + } - @app.get("/products") - def list_products(): - ctx = { - **app.current_event.headers, - **app.current_event.json_body - } + # all_features is evaluated to ["geo_customer_campaign", "ten_percent_off_campaign"] + all_features: list[str] = feature_flags.get_enabled_features(context=ctx) - # all_features is evaluated to ["geo_customer_campaign", "ten_percent_off_campaign"] - all_features: list[str] = feature_flags.get_enabled_features(context=ctx) + if "geo_customer_campaign" in all_features: + # apply discounts based on geo + ... - if "geo_customer_campaign" in all_features: - # apply discounts based on geo - ... + if "ten_percent_off_campaign" in all_features: + # apply additional 10% for all customers + ... - if "ten_percent_off_campaign" in all_features: - # apply additional 10% for all customers - ... - - def lambda_handler(event, context): - return app.resolve(event, context) + def lambda_handler(event, context): + return app.resolve(event, context) ``` === "event.json" - ```json hl_lines="2 8" - { - "body": '{"username": "lessa", "tier": "premium", "basked_id": "random_id"}', - "resource": "/products", - "path": "/products", - "httpMethod": "GET", - "isBase64Encoded": false, - "headers": { - "CloudFront-Viewer-Country": "NL", - } - } - ``` - + ```json hl_lines="2 8" + { + "body": "{\"username\": \"lessa\", \"tier\": \"premium\", \"basked_id\": \"random_id\"}", + "resource": "/products", + "path": "/products", + "httpMethod": "GET", + "isBase64Encoded": false, + "headers": { + "CloudFront-Viewer-Country": "NL" + } + } + ``` === "features.json" ```json hl_lines="17-18 20 27-29" - { - "premium_features": { - "default": false, - "rules": { - "customer tier equals premium": { - "when_match": true, - "conditions": [ - { - "action": "EQUALS", - "key": "tier", - "value": "premium" - } - ] - } - } - }, - "ten_percent_off_campaign": { - "default": true - }, - "geo_customer_campaign": { - "default": false, - "rules": { - "customer in temporary discount geo": { - "when_match": true, - "conditions": [ - { - "action": "IN", - "key": "CloudFront-Viewer-Country", - "value": ["NL", "IE", "UK", "PL", "PT"}, - } - ] - } - } - } + { + "premium_features": { + "default": false, + "rules": { + "customer tier equals premium": { + "when_match": true, + "conditions": [ + { + "action": "EQUALS", + "key": "tier", + "value": "premium" + } + ] + } + } + }, + "ten_percent_off_campaign": { + "default": true + }, + "geo_customer_campaign": { + "default": false, + "rules": { + "customer in temporary discount geo": { + "when_match": true, + "conditions": [ + { + "action": "IN", + "key": "CloudFront-Viewer-Country", + "value": ["NL", "IE", "UK", "PL", "PT"] + } + ] + } + } + } } ``` - ## Advanced ### Schema @@ -391,13 +388,14 @@ This utility expects a certain schema to be stored as JSON within AWS AppConfig. A feature can simply have its name and a `default` value. This is either on or off, also known as a [static flag](#static-flags). === "minimal_schema.json" - ```json hl_lines="2-3" - { - "global_feature": { - "default": true - } - } - ``` + + ```json hl_lines="2-3" + { + "global_feature": { + "default": true + } + } + ``` If you need more control and want to provide context such as user group, permissions, location, etc., you need to add rules to your feature flag configuration. @@ -411,25 +409,25 @@ When adding `rules` to a feature, they must contain: === "feature_with_rules.json" - ```json hl_lines="4-11" - { - "premium_feature": { - "default": false, - "rules": { - "customer tier equals premium": { - "when_match": true, - "conditions": [ - { - "action": "EQUALS", - "key": "tier", - "value": "premium" - } - ] - } - } - } - } - ``` + ```json hl_lines="4-11" + { + "premium_feature": { + "default": false, + "rules": { + "customer tier equals premium": { + "when_match": true, + "conditions": [ + { + "action": "EQUALS", + "key": "tier", + "value": "premium" + } + ] + } + } + } + } + ``` You can have multiple rules with different names. The rule engine will return the first result `when_match` of the matching rule configuration, or `default` value when none of the rules apply. @@ -438,16 +436,17 @@ You can have multiple rules with different names. The rule engine will return th The `conditions` block is a list of conditions that contain `action`, `key`, and `value` keys: === "conditions.json" - ```json hl_lines="8-11" + + ```json hl_lines="5-7" { - ... - "conditions": [ - { - "action": "EQUALS", - "key": "tier", - "value": "premium" - } - ] + ... + "conditions": [ + { + "action": "EQUALS", + "key": "tier", + "value": "premium" + } + ] } ``` @@ -469,16 +468,16 @@ By default, we cache configuration retrieved from the Store for 5 seconds for pe You can override `max_age` parameter when instantiating the store. -```python hl_lines="7" -from aws_lambda_powertools.utilities.feature_flags import FeatureFlags, AppConfigStore + ```python hl_lines="7" + from aws_lambda_powertools.utilities.feature_flags import FeatureFlags, AppConfigStore -app_config = AppConfigStore( - environment="dev", - application="product-catalogue", - name="features", - max_age=300 -) -``` + app_config = AppConfigStore( + environment="dev", + application="product-catalogue", + name="features", + max_age=300 + ) + ``` ### Envelope @@ -488,47 +487,47 @@ For this to work, you need to use a JMESPath expression via the `envelope` param === "app.py" - ```python hl_lines="7" - from aws_lambda_powertools.utilities.feature_flags import FeatureFlags, AppConfigStore + ```python hl_lines="7" + from aws_lambda_powertools.utilities.feature_flags import FeatureFlags, AppConfigStore - app_config = AppConfigStore( - environment="dev", - application="product-catalogue", - name="configuration", - envelope = "feature_flags" - ) - ``` + app_config = AppConfigStore( + environment="dev", + application="product-catalogue", + name="configuration", + envelope = "feature_flags" + ) + ``` === "configuration.json" - ```json hl_lines="6" - { - "logging": { - "level": "INFO", - "sampling_rate": 0.1 - }, - "feature_flags": { - "premium_feature": { - "default": false, - "rules": { - "customer tier equals premium": { - "when_match": true, - "conditions": [ - { - "action": "EQUALS", - "key": "tier", - "value": "premium" - } - ] - } - } - }, - "feature2": { - "default": false - } - } - } - ``` + ```json hl_lines="6" + { + "logging": { + "level": "INFO", + "sampling_rate": 0.1 + }, + "feature_flags": { + "premium_feature": { + "default": false, + "rules": { + "customer tier equals premium": { + "when_match": true, + "conditions": [ + { + "action": "EQUALS", + "key": "tier", + "value": "premium" + } + ] + } + } + }, + "feature2": { + "default": false + } + } + } + ``` ### Built-in store provider @@ -552,35 +551,34 @@ Parameter | Default | Description === "appconfig_store_example.py" -```python hl_lines="19-25" -from botocore.config import Config + ```python hl_lines="19-25" + from botocore.config import Config -import jmespath + import jmespath -boto_config = Config(read_timeout=10, retries={"total_max_attempts": 2}) + boto_config = Config(read_timeout=10, retries={"total_max_attempts": 2}) -# Custom JMESPath functions -class CustomFunctions(jmespath.functions.Functions): + # Custom JMESPath functions + class CustomFunctions(jmespath.functions.Functions): - @jmespath.functions.signature({'types': ['string']}) - def _func_special_decoder(self, s): - return my_custom_decoder_logic(s) + @jmespath.functions.signature({'types': ['string']}) + def _func_special_decoder(self, s): + return my_custom_decoder_logic(s) -custom_jmespath_options = {"custom_functions": CustomFunctions()} + custom_jmespath_options = {"custom_functions": CustomFunctions()} -app_config = AppConfigStore( - environment="dev", - application="product-catalogue", - name="configuration", - max_age=120, - envelope = "features", - sdk_config=boto_config, - jmespath_options=custom_jmespath_options -) -``` - + app_config = AppConfigStore( + environment="dev", + application="product-catalogue", + name="configuration", + max_age=120, + envelope = "features", + sdk_config=boto_config, + jmespath_options=custom_jmespath_options + ) + ``` ## Testing your code @@ -593,56 +591,56 @@ You can unit test your feature flags locally and independently without setting u === "test_feature_flags_independently.py" ```python hl_lines="9-11" - from typing import Dict, List, Optional - - from aws_lambda_powertools.utilities.feature_flags import FeatureFlags, AppConfigStore, RuleAction - - - def init_feature_flags(mocker, mock_schema, envelope="") -> FeatureFlags: - """Mock AppConfig Store get_configuration method to use mock schema instead""" - - method_to_mock = "aws_lambda_powertools.utilities.feature_flags.AppConfigStore.get_configuration" - mocked_get_conf = mocker.patch(method_to_mock) - mocked_get_conf.return_value = mock_schema - - app_conf_store = AppConfigStore( - environment="test_env", - application="test_app", - name="test_conf_name", - envelope=envelope, - ) - - return FeatureFlags(store=app_conf_store) - - - def test_flags_condition_match(mocker): - # GIVEN - expected_value = True - mocked_app_config_schema = { - "my_feature": { - "default": expected_value, - "rules": { - "tenant id equals 12345": { - "when_match": True, - "conditions": [ - { - "action": RuleAction.EQUALS.value, - "key": "tenant_id", - "value": "12345", - } - ], - } - }, - } - } - - # WHEN - ctx = {"tenant_id": "12345", "username": "a"} - feature_flags = init_feature_flags(mocker=mocker, mock_schema=mocked_app_config_schema) - flag = feature_flags.evaluate(name="my_feature", context=ctx, default=False) - - # THEN - assert flag == expected_value + from typing import Dict, List, Optional + + from aws_lambda_powertools.utilities.feature_flags import FeatureFlags, AppConfigStore, RuleAction + + + def init_feature_flags(mocker, mock_schema, envelope="") -> FeatureFlags: + """Mock AppConfig Store get_configuration method to use mock schema instead""" + + method_to_mock = "aws_lambda_powertools.utilities.feature_flags.AppConfigStore.get_configuration" + mocked_get_conf = mocker.patch(method_to_mock) + mocked_get_conf.return_value = mock_schema + + app_conf_store = AppConfigStore( + environment="test_env", + application="test_app", + name="test_conf_name", + envelope=envelope, + ) + + return FeatureFlags(store=app_conf_store) + + + def test_flags_condition_match(mocker): + # GIVEN + expected_value = True + mocked_app_config_schema = { + "my_feature": { + "default": expected_value, + "rules": { + "tenant id equals 12345": { + "when_match": True, + "conditions": [ + { + "action": RuleAction.EQUALS.value, + "key": "tenant_id", + "value": "12345", + } + ], + } + }, + } + } + + # WHEN + ctx = {"tenant_id": "12345", "username": "a"} + feature_flags = init_feature_flags(mocker=mocker, mock_schema=mocked_app_config_schema) + flag = feature_flags.evaluate(name="my_feature", context=ctx, default=False) + + # THEN + assert flag == expected_value ``` ## Feature flags vs Parameters vs env vars diff --git a/docs/utilities/idempotency.md b/docs/utilities/idempotency.md index a684695b36c..d941946b681 100644 --- a/docs/utilities/idempotency.md +++ b/docs/utilities/idempotency.md @@ -77,7 +77,7 @@ TTL attribute name | `expiration` | This can only be configured after your table !!! warning "Large responses with DynamoDB persistence layer" When using this utility with DynamoDB, your function's responses must be [smaller than 400KB](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Limits.html#limits-items). - Larger items cannot be written to DynamoDB and will cause exceptions. + Larger items cannot be written to DynamoDB and will cause exceptions. !!! info "DynamoDB " Each function invocation will generally make 2 requests to DynamoDB. If the @@ -121,7 +121,83 @@ You can quickly start by initializing the `DynamoDBPersistenceLayer` class and u } ``` -#### Choosing a payload subset for idempotency +### Idempotent_function decorator + +Similar to [idempotent decorator](#idempotent-decorator), you can use `idempotent_function` decorator for any synchronous Python function. + +When using `idempotent_function`, you must tell us which keyword parameter in your function signature has the data we should use via **`data_keyword_argument`** - Such data must be JSON serializable. + + + +!!! warning "Make sure to call your decorated function using keyword arguments" + +=== "app.py" + + This example also demonstrates how you can integrate with [Batch utility](batch.md), so you can process each record in an idempotent manner. + + ```python hl_lines="4 13 18 25" + import uuid + + from aws_lambda_powertools.utilities.batch import sqs_batch_processor + from aws_lambda_powertools.utilities.idempotency import idempotent_function, DynamoDBPersistenceLayer, IdempotencyConfig + + + dynamodb = DynamoDBPersistenceLayer(table_name="idem") + config = IdempotencyConfig( + event_key_jmespath="messageId", # see "Choosing a payload subset for idempotency" section + use_local_cache=True, + ) + + @idempotent_function(data_keyword_argument="data", config=config, persistence_store=dynamodb) + def dummy(arg_one, arg_two, data: dict, **kwargs): + return {"data": data} + + + @idempotent_function(data_keyword_argument="record", config=config, persistence_store=dynamodb) + def record_handler(record): + return {"message": record["body"]} + + + @sqs_batch_processor(record_handler=record_handler) + def lambda_handler(event, context): + # `data` parameter must be called as a keyword argument to work + dummy("hello", "universe", data="test") + return {"statusCode": 200} + ``` + +=== "Example event" + + ```json hl_lines="4" + { + "Records": [ + { + "messageId": "059f36b4-87a3-44ab-83d2-661975830a7d", + "receiptHandle": "AQEBwJnKyrHigUMZj6rYigCgxlaS3SLy0a...", + "body": "Test message.", + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "1545082649183", + "SenderId": "AIDAIENQZJOLO23YVJ4VO", + "ApproximateFirstReceiveTimestamp": "1545082649185" + }, + "messageAttributes": { + "testAttr": { + "stringValue": "100", + "binaryValue": "base64Str", + "dataType": "Number" + } + }, + "md5OfBody": "e4e68fb7bd0e697a0ae8f1bb342846b3", + "eventSource": "aws:sqs", + "eventSourceARN": "arn:aws:sqs:us-east-2:123456789012:my-queue", + "awsRegion": "us-east-2" + } + ] + } + ``` + + +### Choosing a payload subset for idempotency !!! tip "Dealing with always changing payloads" When dealing with a more elaborate payload, where parts of the payload always change, you should use **`event_key_jmespath`** parameter. @@ -198,7 +274,7 @@ Imagine the function executes successfully, but the client never receives the re } ``` -#### Idempotency request flow +### Idempotency request flow This sequence diagram shows an example flow of what happens in the payment scenario: @@ -306,6 +382,7 @@ You can enable in-memory caching with the **`use_local_cache`** parameter: ``` When enabled, the default is to cache a maximum of 256 records in each Lambda execution environment - You can change it with the **`local_cache_max_items`** parameter. + ### Expiring idempotency records !!! note diff --git a/docs/utilities/middleware_factory.md b/docs/utilities/middleware_factory.md index b0f5d4a1ccd..366ae7eda66 100644 --- a/docs/utilities/middleware_factory.md +++ b/docs/utilities/middleware_factory.md @@ -107,6 +107,8 @@ For advanced use cases, you can instantiate [Tracer](../core/tracer.md) inside y When unit testing middlewares with `trace_execution` option enabled, use `POWERTOOLS_TRACE_DISABLED` env var to safely disable Tracer. -```bash -POWERTOOLS_TRACE_DISABLED=1 python -m pytest -``` +=== "shell" + + ```bash + POWERTOOLS_TRACE_DISABLED=1 python -m pytest + ``` diff --git a/docs/utilities/parameters.md b/docs/utilities/parameters.md index 871ea199e5a..081d22817ab 100644 --- a/docs/utilities/parameters.md +++ b/docs/utilities/parameters.md @@ -201,11 +201,12 @@ The DynamoDB Provider does not have any high-level functions, as it needs to kno You can initialize the DynamoDB provider pointing to [DynamoDB Local](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/DynamoDBLocal.html) using **`endpoint_url`** parameter: === "dynamodb_local.py" - ```python hl_lines="3" - from aws_lambda_powertools.utilities import parameters - dynamodb_provider = parameters.DynamoDBProvider(table_name="my-table", endpoint_url="http://localhost:8000") - ``` + ```python hl_lines="3" + from aws_lambda_powertools.utilities import parameters + + dynamodb_provider = parameters.DynamoDBProvider(table_name="my-table", endpoint_url="http://localhost:8000") + ``` **DynamoDB table structure for single parameters** @@ -218,7 +219,7 @@ For single parameters, you must use `id` as the [partition key](https://docs.aws > **Example** === "app.py" - With this table, the return value of `dynamodb_provider.get("my-param")` call will be `my-value`. + With this table, the return value of `dynamodb_provider.get("my-param")` call will be `my-value`. ```python hl_lines="3 7" from aws_lambda_powertools.utilities import parameters @@ -242,7 +243,6 @@ For example, if you want to retrieve multiple parameters having `my-hash-key` as | my-hash-key | param-b | my-value-b | | my-hash-key | param-c | my-value-c | - With this table, the return of `dynamodb_provider.get_multiple("my-hash-key")` call will be a dictionary like: ```json diff --git a/docs/utilities/parser.md b/docs/utilities/parser.md index 11dbaca48a8..47f87e355bb 100644 --- a/docs/utilities/parser.md +++ b/docs/utilities/parser.md @@ -230,7 +230,6 @@ You can extend them to include your own models, and yet have all other known fie 3. Defined how part of our EventBridge event should look like by overriding `detail` key within our `OrderEventModel` 4. Parser parsed the original event against `OrderEventModel` - ## Envelopes When trying to parse your payloads wrapped in a known structure, you might encounter the following situations: @@ -291,7 +290,6 @@ Here's an example of parsing a model found in an event coming from EventBridge, 3. Parser parsed the original event against the EventBridge model 4. Parser then parsed the `detail` key using `UserModel` - ### Built-in envelopes Parser comes with the following built-in envelopes, where `Model` in the return section is your given model. @@ -307,6 +305,7 @@ Parser comes with the following built-in envelopes, where `Model` in the return | **SnsSqsEnvelope** | 1. Parses data using `SqsModel`.
2. Parses SNS records in `body` key using `SnsNotificationModel`.
3. Parses data in `Message` key using your model and return them in a list. | `List[Model]` | | **ApiGatewayEnvelope** | 1. Parses data using `APIGatewayProxyEventModel`.
2. Parses `body` key using your model and returns it. | `Model` | | **ApiGatewayV2Envelope** | 1. Parses data using `APIGatewayProxyEventV2Model`.
2. Parses `body` key using your model and returns it. | `Model` | + ### Bringing your own envelope You can create your own Envelope model and logic by inheriting from `BaseEnvelope`, and implementing the `parse` method. @@ -475,7 +474,6 @@ Alternatively, you can pass `'*'` as an argument for the decorator so that you c !!! info You can read more about validating list items, reusing validators, validating raw inputs, and a lot more in Pydantic's documentation. - ## Advanced use cases !!! info @@ -557,19 +555,19 @@ Artillery load test sample against a [hello world sample](https://github.com/aws ``` Summary report @ 14:36:07(+0200) 2020-10-23 - Scenarios launched: 10 - Scenarios completed: 10 - Requests completed: 2000 - Mean response/sec: 114.81 - Response time (msec): +Scenarios launched: 10 +Scenarios completed: 10 +Requests completed: 2000 +Mean response/sec: 114.81 +Response time (msec): min: 54.9 max: 1684.9 median: 68 p95: 109.1 p99: 180.3 - Scenario counts: +Scenario counts: 0: 10 (100%) - Codes: +Codes: 200: 2000 ``` @@ -579,18 +577,18 @@ Summary report @ 14:36:07(+0200) 2020-10-23 ``` Summary report @ 14:29:23(+0200) 2020-10-23 - Scenarios launched: 10 - Scenarios completed: 10 - Requests completed: 2000 - Mean response/sec: 111.67 - Response time (msec): +Scenarios launched: 10 +Scenarios completed: 10 +Requests completed: 2000 +Mean response/sec: 111.67 +Response time (msec): min: 54.3 max: 1887.2 median: 66.1 p95: 113.3 p99: 193.1 - Scenario counts: +Scenario counts: 0: 10 (100%) - Codes: +Codes: 200: 2000 ``` diff --git a/docs/utilities/validation.md b/docs/utilities/validation.md index 3a32500f122..7df339b7503 100644 --- a/docs/utilities/validation.md +++ b/docs/utilities/validation.md @@ -134,7 +134,6 @@ Here is a sample custom EventBridge event, where we only validate what's inside --8<-- "docs/shared/validation_basic_jsonschema.py" ``` - This is quite powerful because you can use JMESPath Query language to extract records from [arrays, slice and dice](https://jmespath.org/tutorial.html#list-and-slice-projections), to [pipe expressions](https://jmespath.org/tutorial.html#pipe-expressions) and [function expressions](https://jmespath.org/tutorial.html#functions), where you'd extract what you need before validating the actual payload. ### Built-in envelopes @@ -165,7 +164,6 @@ This utility comes with built-in envelopes to easily extract the payload from po --8<-- "docs/shared/validation_basic_jsonschema.py" ``` - Here is a handy table with built-in envelopes along with their JMESPath expressions in case you want to build your own. Envelope name | JMESPath expression @@ -189,12 +187,13 @@ Envelope name | JMESPath expression JSON Schemas with custom formats like `int64` will fail validation. If you have these, you can pass them using `formats` parameter: === "custom_json_schema_type_format.json" + ```json { - "lastModifiedTime": { - "format": "int64", - "type": "integer" - } + "lastModifiedTime": { + "format": "int64", + "type": "integer" + } } ``` @@ -209,7 +208,7 @@ For each format defined in a dictionary key, you must use a regex, or a function custom_format = { "int64": True, # simply ignore it, - "positive": lambda x: False if x < 0 else True + "positive": lambda x: False if x < 0 else True } validate(event=event, schema=schemas.INPUT, formats=custom_format) @@ -352,6 +351,7 @@ For each format defined in a dictionary key, you must use a regex, or a function ``` === "event.json" + ```json { "account": "123456789012", @@ -460,7 +460,6 @@ This sample will decode the value within the `data` key into a valid JSON before --8<-- "docs/shared/validation_basic_jsonschema.py" ``` - #### powertools_base64 function Use `powertools_base64` function to decode any base64 data. diff --git a/poetry.lock b/poetry.lock index ac68acc59ed..6b74a886218 100644 --- a/poetry.lock +++ b/poetry.lock @@ -81,14 +81,14 @@ d = ["aiohttp (>=3.3.2)", "aiohttp-cors"] [[package]] name = "boto3" -version = "1.18.17" +version = "1.18.26" description = "The AWS SDK for Python" category = "main" optional = false python-versions = ">= 3.6" [package.dependencies] -botocore = ">=1.21.17,<1.22.0" +botocore = ">=1.21.26,<1.22.0" jmespath = ">=0.7.1,<1.0.0" s3transfer = ">=0.5.0,<0.6.0" @@ -97,7 +97,7 @@ crt = ["botocore[crt] (>=1.21.0,<2.0a0)"] [[package]] name = "botocore" -version = "1.21.17" +version = "1.21.26" description = "Low-level, data-driven core of boto 3." category = "main" optional = false @@ -273,7 +273,7 @@ test = ["coverage", "coveralls", "mock", "pytest", "pytest-cov"] [[package]] name = "flake8-comprehensions" -version = "3.5.0" +version = "3.6.1" description = "A flake8 plugin to help you write better list/set/dict comprehensions." category = "dev" optional = false @@ -360,6 +360,20 @@ category = "main" optional = false python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" +[[package]] +name = "ghp-import" +version = "2.0.1" +description = "Copy your docs directly to the gh-pages branch." +category = "dev" +optional = false +python-versions = "*" + +[package.dependencies] +python-dateutil = ">=2.8.1" + +[package.extras] +dev = ["twine", "markdown", "flake8"] + [[package]] name = "gitdb" version = "4.0.7" @@ -451,42 +465,6 @@ category = "main" optional = false python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" -[[package]] -name = "joblib" -version = "1.0.1" -description = "Lightweight pipelining with Python functions" -category = "dev" -optional = false -python-versions = ">=3.6" - -[[package]] -name = "livereload" -version = "2.6.3" -description = "Python LiveReload is an awesome tool for web developers" -category = "dev" -optional = false -python-versions = "*" - -[package.dependencies] -six = "*" -tornado = {version = "*", markers = "python_version > \"2.7\""} - -[[package]] -name = "lunr" -version = "0.5.8" -description = "A Python implementation of Lunr.js" -category = "dev" -optional = false -python-versions = "*" - -[package.dependencies] -future = ">=0.16.0" -nltk = {version = ">=3.2.5", optional = true, markers = "python_version > \"2.7\" and extra == \"languages\""} -six = ">=1.11.0" - -[package.extras] -languages = ["nltk (>=3.2.5,<3.5)", "nltk (>=3.2.5)"] - [[package]] name = "mako" version = "1.1.4" @@ -546,6 +524,14 @@ category = "dev" optional = false python-versions = "*" +[[package]] +name = "mergedeep" +version = "1.3.4" +description = "A deep merge function for 🐍." +category = "dev" +optional = false +python-versions = ">=3.6" + [[package]] name = "mike" version = "0.6.0" @@ -566,20 +552,26 @@ test = ["coverage", "flake8 (>=3.0)"] [[package]] name = "mkdocs" -version = "1.1.2" +version = "1.2.2" description = "Project documentation with Markdown." category = "dev" optional = false -python-versions = ">=3.5" +python-versions = ">=3.6" [package.dependencies] click = ">=3.3" +ghp-import = ">=1.0" +importlib-metadata = ">=3.10" Jinja2 = ">=2.10.1" -livereload = ">=2.5.1" -lunr = {version = "0.5.8", extras = ["languages"]} Markdown = ">=3.2.1" +mergedeep = ">=1.3.4" +packaging = ">=20.5" PyYAML = ">=3.10" -tornado = ">=5.0" +pyyaml-env-tag = ">=0.1" +watchdog = ">=2.0" + +[package.extras] +i18n = ["babel (>=2.9.0)"] [[package]] name = "mkdocs-git-revision-date-plugin" @@ -596,7 +588,7 @@ mkdocs = ">=0.17" [[package]] name = "mkdocs-material" -version = "7.2.3" +version = "7.2.4" description = "A Material Design theme for MkDocs" category = "dev" optional = false @@ -604,7 +596,7 @@ python-versions = "*" [package.dependencies] markdown = ">=3.2" -mkdocs = ">=1.1" +mkdocs = ">=1.2.2" mkdocs-material-extensions = ">=1.0" Pygments = ">=2.4" pymdown-extensions = ">=7.0" @@ -646,28 +638,6 @@ category = "dev" optional = false python-versions = "*" -[[package]] -name = "nltk" -version = "3.6.2" -description = "Natural Language Toolkit" -category = "dev" -optional = false -python-versions = ">=3.5.*" - -[package.dependencies] -click = "*" -joblib = "*" -regex = "*" -tqdm = "*" - -[package.extras] -all = ["matplotlib", "twython", "scipy", "numpy", "gensim (<4.0.0)", "python-crfsuite", "pyparsing", "scikit-learn", "requests"] -corenlp = ["requests"] -machine_learning = ["gensim (<4.0.0)", "numpy", "python-crfsuite", "scikit-learn", "scipy"] -plot = ["matplotlib"] -tgrep = ["pyparsing"] -twitter = ["twython"] - [[package]] name = "packaging" version = "20.9" @@ -873,6 +843,17 @@ category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" +[[package]] +name = "pyyaml-env-tag" +version = "0.1" +description = "A custom YAML tag for referencing environment variables in YAML files. " +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +pyyaml = "*" + [[package]] name = "radon" version = "4.5.2" @@ -999,27 +980,6 @@ category = "dev" optional = false python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" -[[package]] -name = "tornado" -version = "6.1" -description = "Tornado is a Python web framework and asynchronous networking library, originally developed at FriendFeed." -category = "dev" -optional = false -python-versions = ">= 3.5" - -[[package]] -name = "tqdm" -version = "4.60.0" -description = "Fast, Extensible Progress Meter" -category = "dev" -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7" - -[package.extras] -dev = ["py-make (>=0.1.0)", "twine", "wheel"] -notebook = ["ipywidgets (>=6)"] -telegram = ["requests"] - [[package]] name = "typed-ast" version = "1.4.3" @@ -1049,6 +1009,17 @@ secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "cer socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] brotli = ["brotlipy (>=0.6.0)"] +[[package]] +name = "watchdog" +version = "2.1.3" +description = "Filesystem events monitoring" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.extras] +watchmedo = ["PyYAML (>=3.10)", "argh (>=0.24.1)"] + [[package]] name = "wrapt" version = "1.12.1" @@ -1088,7 +1059,7 @@ pydantic = ["pydantic", "email-validator"] [metadata] lock-version = "1.1" python-versions = "^3.6.1" -content-hash = "f1f9f5b0dfe99881c9ec59adc3b58e8802d23b503d45ebc9908762e36af57c11" +content-hash = "fb53ec392314b8681a5b2c82957b36af88ca1ac9113bfe33bc6239a0c3101853" [metadata.files] appdirs = [ @@ -1115,12 +1086,12 @@ black = [ {file = "black-20.8b1.tar.gz", hash = "sha256:1c02557aa099101b9d21496f8a914e9ed2222ef70336404eeeac8edba836fbea"}, ] boto3 = [ - {file = "boto3-1.18.17-py3-none-any.whl", hash = "sha256:69a5ebbd5fda6742d20fd536cd9b2927f2eaa8dde84ad529fe816231afcf9c68"}, - {file = "boto3-1.18.17.tar.gz", hash = "sha256:5e5f60ece9b73d48f668bef56ddcde716f013b48a62fdf9c5eac9512a5981136"}, + {file = "boto3-1.18.26-py3-none-any.whl", hash = "sha256:3eedef0719639892dc263bed7adfc00821544388e3d86a6fa31d82f936a6b613"}, + {file = "boto3-1.18.26.tar.gz", hash = "sha256:39ed0f5004b671e4a4241ae23023ad63674cd25f766bfe1617cfc809728bc3e0"}, ] botocore = [ - {file = "botocore-1.21.17-py3-none-any.whl", hash = "sha256:5b665142bdb2c30fc86b15bc48dd8b74c9cac69dc3e20b6d8f79cb60ff368797"}, - {file = "botocore-1.21.17.tar.gz", hash = "sha256:a0d64369857d86b3a6d01b0c5933671c2394584311ce3af702271ba221b09afa"}, + {file = "botocore-1.21.26-py3-none-any.whl", hash = "sha256:37f77bf5f72c86d9b5f38e107c3822da66dbf0ef123768a6e80a58590c2796bf"}, + {file = "botocore-1.21.26.tar.gz", hash = "sha256:911246faac450e13a3ef0e81993dd9bd9c282a7c0f4546bfe8cccf9649364cef"}, ] certifi = [ {file = "certifi-2020.12.5-py2.py3-none-any.whl", hash = "sha256:719a74fb9e33b9bd44cc7f3a8d94bc35e4049deebe19ba7d8e108280cfd59830"}, @@ -1228,8 +1199,8 @@ flake8-builtins = [ {file = "flake8_builtins-1.5.3-py2.py3-none-any.whl", hash = "sha256:7706babee43879320376861897e5d1468e396a40b8918ed7bccf70e5f90b8687"}, ] flake8-comprehensions = [ - {file = "flake8-comprehensions-3.5.0.tar.gz", hash = "sha256:f24be9032587127f7a5bc6d066bf755b6e66834f694383adb8a673e229c1f559"}, - {file = "flake8_comprehensions-3.5.0-py3-none-any.whl", hash = "sha256:b07aef3277623db32310aa241a1cec67212b53c1d18e767d7e26d4d83aa05bf7"}, + {file = "flake8-comprehensions-3.6.1.tar.gz", hash = "sha256:4888de89248b7f7535159189ff693c77f8354f6d37a02619fa28c9921a913aa0"}, + {file = "flake8_comprehensions-3.6.1-py3-none-any.whl", hash = "sha256:e9a010b99aa90c05790d45281ad9953df44a4a08a1a8f6cd41f98b4fc6a268a0"}, ] flake8-debugger = [ {file = "flake8-debugger-4.0.0.tar.gz", hash = "sha256:e43dc777f7db1481db473210101ec2df2bd39a45b149d7218a618e954177eda6"}, @@ -1257,6 +1228,9 @@ flake8-variables-names = [ future = [ {file = "future-0.18.2.tar.gz", hash = "sha256:b1bead90b70cf6ec3f0710ae53a525360fa360d306a86583adc6bf83a4db537d"}, ] +ghp-import = [ + {file = "ghp-import-2.0.1.tar.gz", hash = "sha256:753de2eace6e0f7d4edfb3cce5e3c3b98cd52aadb80163303d1d036bda7b4483"}, +] gitdb = [ {file = "gitdb-4.0.7-py3-none-any.whl", hash = "sha256:6c4cc71933456991da20917998acbe6cf4fb41eeaab7d6d67fbc05ecd4c865b0"}, {file = "gitdb-4.0.7.tar.gz", hash = "sha256:96bf5c08b157a666fec41129e6d327235284cca4c81e92109260f353ba138005"}, @@ -1289,17 +1263,6 @@ jmespath = [ {file = "jmespath-0.10.0-py2.py3-none-any.whl", hash = "sha256:cdf6525904cc597730141d61b36f2e4b8ecc257c420fa2f4549bac2c2d0cb72f"}, {file = "jmespath-0.10.0.tar.gz", hash = "sha256:b85d0567b8666149a93172712e68920734333c0ce7e89b78b3e987f71e5ed4f9"}, ] -joblib = [ - {file = "joblib-1.0.1-py3-none-any.whl", hash = "sha256:feeb1ec69c4d45129954f1b7034954241eedfd6ba39b5e9e4b6883be3332d5e5"}, - {file = "joblib-1.0.1.tar.gz", hash = "sha256:9c17567692206d2f3fb9ecf5e991084254fe631665c450b443761c4186a613f7"}, -] -livereload = [ - {file = "livereload-2.6.3.tar.gz", hash = "sha256:776f2f865e59fde56490a56bcc6773b6917366bce0c267c60ee8aaf1a0959869"}, -] -lunr = [ - {file = "lunr-0.5.8-py2.py3-none-any.whl", hash = "sha256:aab3f489c4d4fab4c1294a257a30fec397db56f0a50273218ccc3efdbf01d6ca"}, - {file = "lunr-0.5.8.tar.gz", hash = "sha256:c4fb063b98eff775dd638b3df380008ae85e6cb1d1a24d1cd81a10ef6391c26e"}, -] mako = [ {file = "Mako-1.1.4-py2.py3-none-any.whl", hash = "sha256:aea166356da44b9b830c8023cd9b557fa856bd8b4035d6de771ca027dfc5cc6e"}, {file = "Mako-1.1.4.tar.gz", hash = "sha256:17831f0b7087c313c0ffae2bcbbd3c1d5ba9eeac9c38f2eb7b50e8c99fe9d5ab"}, @@ -1313,12 +1276,22 @@ markdown = [ {file = "Markdown-3.3.4.tar.gz", hash = "sha256:31b5b491868dcc87d6c24b7e3d19a0d730d59d3e46f4eea6430a321bed387a49"}, ] markupsafe = [ + {file = "MarkupSafe-2.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d8446c54dc28c01e5a2dbac5a25f071f6653e6e40f3a8818e8b45d790fe6ef53"}, + {file = "MarkupSafe-2.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:36bc903cbb393720fad60fc28c10de6acf10dc6cc883f3e24ee4012371399a38"}, + {file = "MarkupSafe-2.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2d7d807855b419fc2ed3e631034685db6079889a1f01d5d9dac950f764da3dad"}, + {file = "MarkupSafe-2.0.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:add36cb2dbb8b736611303cd3bfcee00afd96471b09cda130da3581cbdc56a6d"}, + {file = "MarkupSafe-2.0.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:168cd0a3642de83558a5153c8bd34f175a9a6e7f6dc6384b9655d2697312a646"}, + {file = "MarkupSafe-2.0.1-cp310-cp310-win32.whl", hash = "sha256:99df47edb6bda1249d3e80fdabb1dab8c08ef3975f69aed437cb69d0a5de1e28"}, + {file = "MarkupSafe-2.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:e0f138900af21926a02425cf736db95be9f4af72ba1bb21453432a07f6082134"}, {file = "MarkupSafe-2.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:f9081981fe268bd86831e5c75f7de206ef275defcb82bc70740ae6dc507aee51"}, {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:0955295dd5eec6cb6cc2fe1698f4c6d84af2e92de33fbcac4111913cd100a6ff"}, {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:0446679737af14f45767963a1a9ef7620189912317d095f2d9ffa183a4d25d2b"}, {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:f826e31d18b516f653fe296d967d700fddad5901ae07c622bb3705955e1faa94"}, {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:fa130dd50c57d53368c9d59395cb5526eda596d3ffe36666cd81a44d56e48872"}, {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:905fec760bd2fa1388bb5b489ee8ee5f7291d692638ea5f67982d968366bef9f"}, + {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bf5d821ffabf0ef3533c39c518f3357b171a1651c1ff6827325e4489b0e46c3c"}, + {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:0d4b31cc67ab36e3392bbf3862cfbadac3db12bdd8b02a2731f509ed5b829724"}, + {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:baa1a4e8f868845af802979fcdbf0bb11f94f1cb7ced4c4b8a351bb60d108145"}, {file = "MarkupSafe-2.0.1-cp36-cp36m-win32.whl", hash = "sha256:6c4ca60fa24e85fe25b912b01e62cb969d69a23a5d5867682dd3e80b5b02581d"}, {file = "MarkupSafe-2.0.1-cp36-cp36m-win_amd64.whl", hash = "sha256:b2f4bf27480f5e5e8ce285a8c8fd176c0b03e93dcc6646477d4630e83440c6a9"}, {file = "MarkupSafe-2.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:0717a7390a68be14b8c793ba258e075c6f4ca819f15edfc2a3a027c823718567"}, @@ -1327,14 +1300,21 @@ markupsafe = [ {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:d7f9850398e85aba693bb640262d3611788b1f29a79f0c93c565694658f4071f"}, {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:6a7fae0dd14cf60ad5ff42baa2e95727c3d81ded453457771d02b7d2b3f9c0c2"}, {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:b7f2d075102dc8c794cbde1947378051c4e5180d52d276987b8d28a3bd58c17d"}, + {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e9936f0b261d4df76ad22f8fee3ae83b60d7c3e871292cd42f40b81b70afae85"}, + {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:2a7d351cbd8cfeb19ca00de495e224dea7e7d919659c2841bbb7f420ad03e2d6"}, + {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:60bf42e36abfaf9aff1f50f52644b336d4f0a3fd6d8a60ca0d054ac9f713a864"}, {file = "MarkupSafe-2.0.1-cp37-cp37m-win32.whl", hash = "sha256:a30e67a65b53ea0a5e62fe23682cfe22712e01f453b95233b25502f7c61cb415"}, {file = "MarkupSafe-2.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:611d1ad9a4288cf3e3c16014564df047fe08410e628f89805e475368bd304914"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:5bb28c636d87e840583ee3adeb78172efc47c8b26127267f54a9c0ec251d41a9"}, {file = "MarkupSafe-2.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:be98f628055368795d818ebf93da628541e10b75b41c559fdf36d104c5787066"}, {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:1d609f577dc6e1aa17d746f8bd3c31aa4d258f4070d61b2aa5c4166c1539de35"}, {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:7d91275b0245b1da4d4cfa07e0faedd5b0812efc15b702576d103293e252af1b"}, {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:01a9b8ea66f1658938f65b93a85ebe8bc016e6769611be228d797c9d998dd298"}, {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:47ab1e7b91c098ab893b828deafa1203de86d0bc6ab587b160f78fe6c4011f75"}, {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:97383d78eb34da7e1fa37dd273c20ad4320929af65d156e35a5e2d89566d9dfb"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6fcf051089389abe060c9cd7caa212c707e58153afa2c649f00346ce6d260f1b"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:5855f8438a7d1d458206a2466bf82b0f104a3724bf96a1c781ab731e4201731a"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:3dd007d54ee88b46be476e293f48c85048603f5f516008bee124ddd891398ed6"}, {file = "MarkupSafe-2.0.1-cp38-cp38-win32.whl", hash = "sha256:023cb26ec21ece8dc3907c0e8320058b2e0cb3c55cf9564da612bc325bed5e64"}, {file = "MarkupSafe-2.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:984d76483eb32f1bcb536dc27e4ad56bba4baa70be32fa87152832cdd9db0833"}, {file = "MarkupSafe-2.0.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:2ef54abee730b502252bcdf31b10dacb0a416229b72c18b19e24a4509f273d26"}, @@ -1344,6 +1324,9 @@ markupsafe = [ {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:4efca8f86c54b22348a5467704e3fec767b2db12fc39c6d963168ab1d3fc9135"}, {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:ab3ef638ace319fa26553db0624c4699e31a28bb2a835c5faca8f8acf6a5a902"}, {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:f8ba0e8349a38d3001fae7eadded3f6606f0da5d748ee53cc1dab1d6527b9509"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c47adbc92fc1bb2b3274c4b3a43ae0e4573d9fbff4f54cd484555edbf030baf1"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:37205cac2a79194e3750b0af2a5720d95f786a55ce7df90c3af697bfa100eaac"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:1f2ade76b9903f39aa442b4aadd2177decb66525062db244b35d71d0ee8599b6"}, {file = "MarkupSafe-2.0.1-cp39-cp39-win32.whl", hash = "sha256:10f82115e21dc0dfec9ab5c0223652f7197feb168c940f3ef61563fc2d6beb74"}, {file = "MarkupSafe-2.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:693ce3f9e70a6cf7d2fb9e6c9d8b204b6b39897a2c4a1aa65728d5ac97dcc1d8"}, {file = "MarkupSafe-2.0.1.tar.gz", hash = "sha256:594c67807fb16238b30c44bdf74f36c02cdf22d1c8cda91ef8a0ed8dabf5620a"}, @@ -1352,21 +1335,25 @@ mccabe = [ {file = "mccabe-0.6.1-py2.py3-none-any.whl", hash = "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42"}, {file = "mccabe-0.6.1.tar.gz", hash = "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"}, ] +mergedeep = [ + {file = "mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307"}, + {file = "mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8"}, +] mike = [ {file = "mike-0.6.0-py3-none-any.whl", hash = "sha256:cef9b9c803ff5c3fbb410f51f5ceb00902a9fe16d9fabd93b69c65cf481ab5a1"}, {file = "mike-0.6.0.tar.gz", hash = "sha256:6d6239de2a60d733da2f34617e9b9a14c4b5437423b47e524f14dc96d6ce5f2f"}, ] mkdocs = [ - {file = "mkdocs-1.1.2-py3-none-any.whl", hash = "sha256:096f52ff52c02c7e90332d2e53da862fde5c062086e1b5356a6e392d5d60f5e9"}, - {file = "mkdocs-1.1.2.tar.gz", hash = "sha256:f0b61e5402b99d7789efa032c7a74c90a20220a9c81749da06dbfbcbd52ffb39"}, + {file = "mkdocs-1.2.2-py3-none-any.whl", hash = "sha256:d019ff8e17ec746afeb54eb9eb4112b5e959597aebc971da46a5c9486137f0ff"}, + {file = "mkdocs-1.2.2.tar.gz", hash = "sha256:a334f5bd98ec960638511366eb8c5abc9c99b9083a0ed2401d8791b112d6b078"}, ] mkdocs-git-revision-date-plugin = [ {file = "mkdocs-git-revision-date-plugin-0.3.1.tar.gz", hash = "sha256:4abaef720763a64c952bed6829dcc180f67c97c60dd73914e90715e05d1cfb23"}, {file = "mkdocs_git_revision_date_plugin-0.3.1-py3-none-any.whl", hash = "sha256:8ae50b45eb75d07b150a69726041860801615aae5f4adbd6b1cf4d51abaa03d5"}, ] mkdocs-material = [ - {file = "mkdocs-material-7.2.3.tar.gz", hash = "sha256:688f0162e7356aa5d019cf687851f5b24c7002886bd939b8159595cc16966edf"}, - {file = "mkdocs_material-7.2.3-py2.py3-none-any.whl", hash = "sha256:039ce80555ba457faa10af993c57e9b6469918c6a5427851dcdd65591cc47fac"}, + {file = "mkdocs-material-7.2.4.tar.gz", hash = "sha256:0e19402480a80add9b0fe777e9be80fafb9583ec2c91e43deaef29d1a432d018"}, + {file = "mkdocs_material-7.2.4-py2.py3-none-any.whl", hash = "sha256:f554c84286b485c7d47e89c14c2fc062fc57b65f9c26fa1687720fe4f569b837"}, ] mkdocs-material-extensions = [ {file = "mkdocs-material-extensions-1.0.1.tar.gz", hash = "sha256:6947fb7f5e4291e3c61405bad3539d81e0b3cd62ae0d66ced018128af509c68f"}, @@ -1401,10 +1388,6 @@ mypy-extensions = [ {file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"}, {file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"}, ] -nltk = [ - {file = "nltk-3.6.2-py3-none-any.whl", hash = "sha256:240e23ab1ab159ef9940777d30c7c72d7e76d91877099218a7585370c11f6b9e"}, - {file = "nltk-3.6.2.zip", hash = "sha256:57d556abed621ab9be225cc6d2df1edce17572efb67a3d754630c9f8381503eb"}, -] packaging = [ {file = "packaging-20.9-py2.py3-none-any.whl", hash = "sha256:67714da7f7bc052e064859c05c595155bd1ee9f69f76557e21f051443c20947a"}, {file = "packaging-20.9.tar.gz", hash = "sha256:5b327ac1320dc863dca72f4514ecc086f31186744b84a230374cc1fd776feae5"}, @@ -1523,6 +1506,10 @@ pyyaml = [ {file = "PyYAML-5.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:c20cfa2d49991c8b4147af39859b167664f2ad4561704ee74c1de03318e898db"}, {file = "PyYAML-5.4.1.tar.gz", hash = "sha256:607774cbba28732bfa802b54baa7484215f530991055bb562efbed5b2f20a45e"}, ] +pyyaml-env-tag = [ + {file = "pyyaml_env_tag-0.1-py3-none-any.whl", hash = "sha256:af31106dec8a4d68c60207c1886031cbf839b68aa7abccdb19868200532c2069"}, + {file = "pyyaml_env_tag-0.1.tar.gz", hash = "sha256:70092675bda14fdec33b31ba77e7543de9ddc88f2e5b99160396572d11525bdb"}, +] radon = [ {file = "radon-4.5.2-py2.py3-none-any.whl", hash = "sha256:0fc191bfb6938e67f881764f7242c163fb3c78fc7acdfc5a0b8254c66ff9dc8b"}, {file = "radon-4.5.2.tar.gz", hash = "sha256:63b863dd294fcc86f6aecace8d7cb4228acc2a16ab0b89c11ff60cb14182b488"}, @@ -1635,53 +1622,6 @@ toml = [ {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, ] -tornado = [ - {file = "tornado-6.1-cp35-cp35m-macosx_10_9_x86_64.whl", hash = "sha256:d371e811d6b156d82aa5f9a4e08b58debf97c302a35714f6f45e35139c332e32"}, - {file = "tornado-6.1-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:0d321a39c36e5f2c4ff12b4ed58d41390460f798422c4504e09eb5678e09998c"}, - {file = "tornado-6.1-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:9de9e5188a782be6b1ce866e8a51bc76a0fbaa0e16613823fc38e4fc2556ad05"}, - {file = "tornado-6.1-cp35-cp35m-manylinux2010_i686.whl", hash = "sha256:61b32d06ae8a036a6607805e6720ef00a3c98207038444ba7fd3d169cd998910"}, - {file = "tornado-6.1-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:3e63498f680547ed24d2c71e6497f24bca791aca2fe116dbc2bd0ac7f191691b"}, - {file = "tornado-6.1-cp35-cp35m-manylinux2014_aarch64.whl", hash = "sha256:6c77c9937962577a6a76917845d06af6ab9197702a42e1346d8ae2e76b5e3675"}, - {file = "tornado-6.1-cp35-cp35m-win32.whl", hash = "sha256:6286efab1ed6e74b7028327365cf7346b1d777d63ab30e21a0f4d5b275fc17d5"}, - {file = "tornado-6.1-cp35-cp35m-win_amd64.whl", hash = "sha256:fa2ba70284fa42c2a5ecb35e322e68823288a4251f9ba9cc77be04ae15eada68"}, - {file = "tornado-6.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:0a00ff4561e2929a2c37ce706cb8233b7907e0cdc22eab98888aca5dd3775feb"}, - {file = "tornado-6.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:748290bf9112b581c525e6e6d3820621ff020ed95af6f17fedef416b27ed564c"}, - {file = "tornado-6.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:e385b637ac3acaae8022e7e47dfa7b83d3620e432e3ecb9a3f7f58f150e50921"}, - {file = "tornado-6.1-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:25ad220258349a12ae87ede08a7b04aca51237721f63b1808d39bdb4b2164558"}, - {file = "tornado-6.1-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:65d98939f1a2e74b58839f8c4dab3b6b3c1ce84972ae712be02845e65391ac7c"}, - {file = "tornado-6.1-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:e519d64089b0876c7b467274468709dadf11e41d65f63bba207e04217f47c085"}, - {file = "tornado-6.1-cp36-cp36m-win32.whl", hash = "sha256:b87936fd2c317b6ee08a5741ea06b9d11a6074ef4cc42e031bc6403f82a32575"}, - {file = "tornado-6.1-cp36-cp36m-win_amd64.whl", hash = "sha256:cc0ee35043162abbf717b7df924597ade8e5395e7b66d18270116f8745ceb795"}, - {file = "tornado-6.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:7250a3fa399f08ec9cb3f7b1b987955d17e044f1ade821b32e5f435130250d7f"}, - {file = "tornado-6.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:ed3ad863b1b40cd1d4bd21e7498329ccaece75db5a5bf58cd3c9f130843e7102"}, - {file = "tornado-6.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:dcef026f608f678c118779cd6591c8af6e9b4155c44e0d1bc0c87c036fb8c8c4"}, - {file = "tornado-6.1-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:70dec29e8ac485dbf57481baee40781c63e381bebea080991893cd297742b8fd"}, - {file = "tornado-6.1-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:d3f7594930c423fd9f5d1a76bee85a2c36fd8b4b16921cae7e965f22575e9c01"}, - {file = "tornado-6.1-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:3447475585bae2e77ecb832fc0300c3695516a47d46cefa0528181a34c5b9d3d"}, - {file = "tornado-6.1-cp37-cp37m-win32.whl", hash = "sha256:e7229e60ac41a1202444497ddde70a48d33909e484f96eb0da9baf8dc68541df"}, - {file = "tornado-6.1-cp37-cp37m-win_amd64.whl", hash = "sha256:cb5ec8eead331e3bb4ce8066cf06d2dfef1bfb1b2a73082dfe8a161301b76e37"}, - {file = "tornado-6.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:20241b3cb4f425e971cb0a8e4ffc9b0a861530ae3c52f2b0434e6c1b57e9fd95"}, - {file = "tornado-6.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:c77da1263aa361938476f04c4b6c8916001b90b2c2fdd92d8d535e1af48fba5a"}, - {file = "tornado-6.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:fba85b6cd9c39be262fcd23865652920832b61583de2a2ca907dbd8e8a8c81e5"}, - {file = "tornado-6.1-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:1e8225a1070cd8eec59a996c43229fe8f95689cb16e552d130b9793cb570a288"}, - {file = "tornado-6.1-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:d14d30e7f46a0476efb0deb5b61343b1526f73ebb5ed84f23dc794bdb88f9d9f"}, - {file = "tornado-6.1-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:8f959b26f2634a091bb42241c3ed8d3cedb506e7c27b8dd5c7b9f745318ddbb6"}, - {file = "tornado-6.1-cp38-cp38-win32.whl", hash = "sha256:34ca2dac9e4d7afb0bed4677512e36a52f09caa6fded70b4e3e1c89dbd92c326"}, - {file = "tornado-6.1-cp38-cp38-win_amd64.whl", hash = "sha256:6196a5c39286cc37c024cd78834fb9345e464525d8991c21e908cc046d1cc02c"}, - {file = "tornado-6.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f0ba29bafd8e7e22920567ce0d232c26d4d47c8b5cf4ed7b562b5db39fa199c5"}, - {file = "tornado-6.1-cp39-cp39-manylinux1_i686.whl", hash = "sha256:33892118b165401f291070100d6d09359ca74addda679b60390b09f8ef325ffe"}, - {file = "tornado-6.1-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:7da13da6f985aab7f6f28debab00c67ff9cbacd588e8477034c0652ac141feea"}, - {file = "tornado-6.1-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:e0791ac58d91ac58f694d8d2957884df8e4e2f6687cdf367ef7eb7497f79eaa2"}, - {file = "tornado-6.1-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:66324e4e1beede9ac79e60f88de548da58b1f8ab4b2f1354d8375774f997e6c0"}, - {file = "tornado-6.1-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:a48900ecea1cbb71b8c71c620dee15b62f85f7c14189bdeee54966fbd9a0c5bd"}, - {file = "tornado-6.1-cp39-cp39-win32.whl", hash = "sha256:d3d20ea5782ba63ed13bc2b8c291a053c8d807a8fa927d941bd718468f7b950c"}, - {file = "tornado-6.1-cp39-cp39-win_amd64.whl", hash = "sha256:548430be2740e327b3fe0201abe471f314741efcb0067ec4f2d7dcfb4825f3e4"}, - {file = "tornado-6.1.tar.gz", hash = "sha256:33c6e81d7bd55b468d2e793517c909b139960b6c790a60b7991b9b6b76fb9791"}, -] -tqdm = [ - {file = "tqdm-4.60.0-py2.py3-none-any.whl", hash = "sha256:daec693491c52e9498632dfbe9ccfc4882a557f5fa08982db1b4d3adbe0887c3"}, - {file = "tqdm-4.60.0.tar.gz", hash = "sha256:ebdebdb95e3477ceea267decfc0784859aa3df3e27e22d23b83e9b272bf157ae"}, -] typed-ast = [ {file = "typed_ast-1.4.3-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:2068531575a125b87a41802130fa7e29f26c09a2833fea68d9a40cf33902eba6"}, {file = "typed_ast-1.4.3-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:c907f561b1e83e93fad565bac5ba9c22d96a54e7ea0267c708bffe863cbe4075"}, @@ -1723,6 +1663,29 @@ urllib3 = [ {file = "urllib3-1.26.4-py2.py3-none-any.whl", hash = "sha256:2f4da4594db7e1e110a944bb1b551fdf4e6c136ad42e4234131391e21eb5b0df"}, {file = "urllib3-1.26.4.tar.gz", hash = "sha256:e7b021f7241115872f92f43c6508082facffbd1c048e3c6e2bb9c2a157e28937"}, ] +watchdog = [ + {file = "watchdog-2.1.3-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:9628f3f85375a17614a2ab5eac7665f7f7be8b6b0a2a228e6f6a2e91dd4bfe26"}, + {file = "watchdog-2.1.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:acc4e2d5be6f140f02ee8590e51c002829e2c33ee199036fcd61311d558d89f4"}, + {file = "watchdog-2.1.3-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:85b851237cf3533fabbc034ffcd84d0fa52014b3121454e5f8b86974b531560c"}, + {file = "watchdog-2.1.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:a12539ecf2478a94e4ba4d13476bb2c7a2e0a2080af2bb37df84d88b1b01358a"}, + {file = "watchdog-2.1.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:6fe9c8533e955c6589cfea6f3f0a1a95fb16867a211125236c82e1815932b5d7"}, + {file = "watchdog-2.1.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:d9456f0433845e7153b102fffeb767bde2406b76042f2216838af3b21707894e"}, + {file = "watchdog-2.1.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:fd8c595d5a93abd441ee7c5bb3ff0d7170e79031520d113d6f401d0cf49d7c8f"}, + {file = "watchdog-2.1.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0bcfe904c7d404eb6905f7106c54873503b442e8e918cc226e1828f498bdc0ca"}, + {file = "watchdog-2.1.3-pp36-pypy36_pp73-macosx_10_9_x86_64.whl", hash = "sha256:bf84bd94cbaad8f6b9cbaeef43080920f4cb0e61ad90af7106b3de402f5fe127"}, + {file = "watchdog-2.1.3-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:b8ddb2c9f92e0c686ea77341dcb58216fa5ff7d5f992c7278ee8a392a06e86bb"}, + {file = "watchdog-2.1.3-py3-none-manylinux2014_aarch64.whl", hash = "sha256:8805a5f468862daf1e4f4447b0ccf3acaff626eaa57fbb46d7960d1cf09f2e6d"}, + {file = "watchdog-2.1.3-py3-none-manylinux2014_armv7l.whl", hash = "sha256:3e305ea2757f81d8ebd8559d1a944ed83e3ab1bdf68bcf16ec851b97c08dc035"}, + {file = "watchdog-2.1.3-py3-none-manylinux2014_i686.whl", hash = "sha256:431a3ea70b20962e6dee65f0eeecd768cd3085ea613ccb9b53c8969de9f6ebd2"}, + {file = "watchdog-2.1.3-py3-none-manylinux2014_ppc64.whl", hash = "sha256:e4929ac2aaa2e4f1a30a36751160be391911da463a8799460340901517298b13"}, + {file = "watchdog-2.1.3-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:201cadf0b8c11922f54ec97482f95b2aafca429c4c3a4bb869a14f3c20c32686"}, + {file = "watchdog-2.1.3-py3-none-manylinux2014_s390x.whl", hash = "sha256:3a7d242a7963174684206093846537220ee37ba9986b824a326a8bb4ef329a33"}, + {file = "watchdog-2.1.3-py3-none-manylinux2014_x86_64.whl", hash = "sha256:54e057727dd18bd01a3060dbf5104eb5a495ca26316487e0f32a394fd5fe725a"}, + {file = "watchdog-2.1.3-py3-none-win32.whl", hash = "sha256:b5fc5c127bad6983eecf1ad117ab3418949f18af9c8758bd10158be3647298a9"}, + {file = "watchdog-2.1.3-py3-none-win_amd64.whl", hash = "sha256:44acad6f642996a2b50bb9ce4fb3730dde08f23e79e20cd3d8e2a2076b730381"}, + {file = "watchdog-2.1.3-py3-none-win_ia64.whl", hash = "sha256:0bcdf7b99b56a3ae069866c33d247c9994ffde91b620eaf0306b27e099bd1ae0"}, + {file = "watchdog-2.1.3.tar.gz", hash = "sha256:e5236a8e8602ab6db4b873664c2d356c365ab3cac96fbdec4970ad616415dd45"}, +] wrapt = [ {file = "wrapt-1.12.1.tar.gz", hash = "sha256:b62ffa81fb85f4332a4f609cab4ac40709470da05643a082ec1eb88e6d9b97d7"}, ] diff --git a/pyproject.toml b/pyproject.toml index 5aeed9fdcf7..7b7b5a9c4ec 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [tool.poetry] name = "aws_lambda_powertools" -version = "1.19.0" -description = "A suite of utilities for AWS Lambda functions to ease adopting best practices such as tracing, structured logging, custom metrics, and more." +version = "1.20.0" +description = "A suite of utilities for AWS Lambda functions to ease adopting best practices such as tracing, structured logging, custom metrics, batching, idempotency, feature flags, and more." authors = ["Amazon Web Services"] include = ["aws_lambda_powertools/py.typed"] classifiers=[ @@ -12,6 +12,7 @@ classifiers=[ "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", ] repository="https://github.com/awslabs/aws-lambda-powertools-python" readme = "README.md" @@ -22,7 +23,7 @@ license = "MIT-0" python = "^3.6.1" aws-xray-sdk = "^2.8.0" fastjsonschema = "^2.14.5" -boto3 = "^1.12" +boto3 = "^1.18" jmespath = "^0.10.0" pydantic = {version = "^1.8.2", optional = true } email-validator = {version = "*", optional = true } @@ -34,7 +35,7 @@ black = "^20.8b1" flake8 = "^3.9.0" flake8-black = "^0.2.3" flake8-builtins = "^1.5.3" -flake8-comprehensions = "^3.4.0" +flake8-comprehensions = "^3.6.1" flake8-debugger = "^4.0.0" flake8-fixme = "^1.1.1" flake8-isort = "^4.0.0" @@ -49,7 +50,7 @@ radon = "^4.5.0" xenon = "^0.7.3" flake8-eradicate = "^1.1.0" flake8-bugbear = "^21.3.2" -mkdocs-material = "^7.2.3" +mkdocs-material = "^7.2.4" mkdocs-git-revision-date-plugin = "^0.3.1" mike = "^0.6.0" mypy = "^0.910" @@ -60,7 +61,7 @@ pydantic = ["pydantic", "email-validator"] [tool.coverage.run] source = ["aws_lambda_powertools"] -omit = ["tests/*"] +omit = ["tests/*", "aws_lambda_powertools/exceptions/*", "aws_lambda_powertools/utilities/parser/types.py"] branch = true [tool.coverage.html] diff --git a/tests/events/apiGatewayAuthorizerRequestEvent.json b/tests/events/apiGatewayAuthorizerRequestEvent.json new file mode 100644 index 00000000000..d8dfe3fecf9 --- /dev/null +++ b/tests/events/apiGatewayAuthorizerRequestEvent.json @@ -0,0 +1,69 @@ +{ + "version": "1.0", + "type": "REQUEST", + "methodArn": "arn:aws:execute-api:us-east-1:123456789012:abcdef123/test/GET/request", + "identitySource": "user1,123", + "authorizationToken": "user1,123", + "resource": "/request", + "path": "/request", + "httpMethod": "GET", + "headers": { + "X-AMZ-Date": "20170718T062915Z", + "Accept": "*/*", + "HeaderAuth1": "headerValue1", + "CloudFront-Viewer-Country": "US", + "CloudFront-Forwarded-Proto": "https", + "CloudFront-Is-Tablet-Viewer": "false", + "CloudFront-Is-Mobile-Viewer": "false", + "User-Agent": "..." + }, + "queryStringParameters": { + "QueryString1": "queryValue1" + }, + "pathParameters": {}, + "stageVariables": { + "StageVar1": "stageValue1" + }, + "requestContext": { + "accountId": "123456789012", + "apiId": "abcdef123", + "domainName": "3npb9j1tlk.execute-api.us-west-1.amazonaws.com", + "domainPrefix": "3npb9j1tlk", + "extendedRequestId": "EXqgWgXxSK4EJug=", + "httpMethod": "GET", + "identity": { + "accessKey": null, + "accountId": null, + "caller": null, + "cognitoAmr": null, + "cognitoAuthenticationProvider": null, + "cognitoAuthenticationType": null, + "cognitoIdentityId": null, + "cognitoIdentityPoolId": null, + "principalOrgId": null, + "apiKey": "...", + "sourceIp": "...", + "user": null, + "userAgent": "PostmanRuntime/7.28.3", + "userArn": null, + "clientCert": { + "clientCertPem": "CERT_CONTENT", + "subjectDN": "www.example.com", + "issuerDN": "Example issuer", + "serialNumber": "a1:a1:a1:a1:a1:a1:a1:a1:a1:a1:a1:a1:a1:a1:a1:a1", + "validity": { + "notBefore": "May 28 12:30:02 2019 GMT", + "notAfter": "Aug 5 09:36:04 2021 GMT" + } + } + }, + "path": "/request", + "protocol": "HTTP/1.1", + "requestId": "EXqgWgXxSK4EJug=", + "requestTime": "20/Aug/2021:14:36:50 +0000", + "requestTimeEpoch": 1629470210043, + "resourceId": "ANY /request", + "resourcePath": "/request", + "stage": "test" + } +} diff --git a/tests/events/apiGatewayAuthorizerTokenEvent.json b/tests/events/apiGatewayAuthorizerTokenEvent.json new file mode 100644 index 00000000000..f30f360f6d8 --- /dev/null +++ b/tests/events/apiGatewayAuthorizerTokenEvent.json @@ -0,0 +1,5 @@ +{ + "type": "TOKEN", + "authorizationToken": "allow", + "methodArn": "arn:aws:execute-api:us-west-2:123456789012:ymy8tbxw7b/*/GET/" +} diff --git a/tests/events/apiGatewayAuthorizerV2Event.json b/tests/events/apiGatewayAuthorizerV2Event.json new file mode 100644 index 00000000000..f0528080c90 --- /dev/null +++ b/tests/events/apiGatewayAuthorizerV2Event.json @@ -0,0 +1,52 @@ +{ + "version": "2.0", + "type": "REQUEST", + "routeArn": "arn:aws:execute-api:us-east-1:123456789012:abcdef123/test/GET/request", + "identitySource": ["user1", "123"], + "routeKey": "GET /merchants", + "rawPath": "/merchants", + "rawQueryString": "parameter1=value1¶meter1=value2¶meter2=value", + "cookies": ["cookie1", "cookie2"], + "headers": { + "x-amzn-trace-id": "Root=1-611cc4a7-0746ebee281cfd967db97b64", + "Header1": "value1", + "Header2": "value2", + "Authorization": "value" + }, + "queryStringParameters": { + "parameter1": "value1,value2", + "parameter2": "value" + }, + "requestContext": { + "accountId": "123456789012", + "apiId": "api-id", + "authentication": { + "clientCert": { + "clientCertPem": "CERT_CONTENT", + "subjectDN": "www.example.com", + "issuerDN": "Example issuer", + "serialNumber": "a1:a1:a1:a1:a1:a1:a1:a1:a1:a1:a1:a1:a1:a1:a1:a1", + "validity": { + "notBefore": "May 28 12:30:02 2019 GMT", + "notAfter": "Aug 5 09:36:04 2021 GMT" + } + } + }, + "domainName": "id.execute-api.us-east-1.amazonaws.com", + "domainPrefix": "id", + "http": { + "method": "POST", + "path": "/merchants", + "protocol": "HTTP/1.1", + "sourceIp": "IP", + "userAgent": "agent" + }, + "requestId": "id", + "routeKey": "GET /merchants", + "stage": "$default", + "time": "12/Mar/2020:19:03:58 +0000", + "timeEpoch": 1583348638390 + }, + "pathParameters": { "parameter1": "value1" }, + "stageVariables": { "stageVariable1": "value1", "stageVariable2": "value2" } +} diff --git a/tests/events/apiGatewayProxyV2Event.json b/tests/events/apiGatewayProxyV2Event.json index 5e001934fee..9de632b8e3d 100644 --- a/tests/events/apiGatewayProxyV2Event.json +++ b/tests/events/apiGatewayProxyV2Event.json @@ -18,6 +18,18 @@ "requestContext": { "accountId": "123456789012", "apiId": "api-id", + "authentication": { + "clientCert": { + "clientCertPem": "CERT_CONTENT", + "subjectDN": "www.example.com", + "issuerDN": "Example issuer", + "serialNumber": "a1:a1:a1:a1:a1:a1:a1:a1:a1:a1:a1:a1:a1:a1:a1:a1", + "validity": { + "notBefore": "May 28 12:30:02 2019 GMT", + "notAfter": "Aug 5 09:36:04 2021 GMT" + } + } + }, "authorizer": { "jwt": { "claims": { @@ -54,4 +66,4 @@ "stageVariable1": "value1", "stageVariable2": "value2" } -} \ No newline at end of file +} diff --git a/tests/events/appSyncAuthorizerEvent.json b/tests/events/appSyncAuthorizerEvent.json new file mode 100644 index 00000000000..a8264569bfc --- /dev/null +++ b/tests/events/appSyncAuthorizerEvent.json @@ -0,0 +1,13 @@ +{ + "authorizationToken": "BE9DC5E3-D410-4733-AF76-70178092E681", + "requestContext": { + "apiId": "giy7kumfmvcqvbedntjwjvagii", + "accountId": "254688921111", + "requestId": "b80ed838-14c6-4500-b4c3-b694c7bef086", + "queryString": "mutation MyNewTask($desc: String!) {\n createTask(description: $desc, owner: \"ccc\", taskStatus: \"cc\", title: \"ccc\") {\n id\n }\n}\n", + "operationName": "MyNewTask", + "variables": { + "desc": "Foo" + } + } +} diff --git a/tests/events/appSyncAuthorizerResponse.json b/tests/events/appSyncAuthorizerResponse.json new file mode 100644 index 00000000000..7dd8234d2ef --- /dev/null +++ b/tests/events/appSyncAuthorizerResponse.json @@ -0,0 +1,9 @@ +{ + "isAuthorized": true, + "resolverContext": { + "name": "Foo Man", + "balance": 100 + }, + "deniedFields": ["Mutation.createEvent"], + "ttlOverride": 15 +} diff --git a/tests/functional/data_classes/__init__.py b/tests/functional/data_classes/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/functional/data_classes/test_api_gateway_authorizer.py b/tests/functional/data_classes/test_api_gateway_authorizer.py new file mode 100644 index 00000000000..7dac6cb7791 --- /dev/null +++ b/tests/functional/data_classes/test_api_gateway_authorizer.py @@ -0,0 +1,147 @@ +import pytest + +from aws_lambda_powertools.utilities.data_classes.api_gateway_authorizer_event import ( + APIGatewayAuthorizerResponse, + HttpVerb, +) + + +@pytest.fixture +def builder(): + return APIGatewayAuthorizerResponse("foo", "us-west-1", "123456789", "fantom", "dev") + + +def test_authorizer_response_no_statement(builder: APIGatewayAuthorizerResponse): + # GIVEN a builder with no statements + with pytest.raises(ValueError) as ex: + # WHEN calling build + builder.asdict() + + # THEN raise a name error for not statements + assert str(ex.value) == "No statements defined for the policy" + + +def test_authorizer_response_invalid_verb(builder: APIGatewayAuthorizerResponse): + with pytest.raises(ValueError, match="Invalid HTTP verb: 'INVALID'"): + # GIVEN a invalid http_method + # WHEN calling deny_method + builder.deny_route(http_method="INVALID", resource="foo") + + +def test_authorizer_response_invalid_resource(builder: APIGatewayAuthorizerResponse): + with pytest.raises(ValueError, match="Invalid resource path: \$."): # noqa: W605 + # GIVEN a invalid resource path "$" + # WHEN calling deny_method + builder.deny_route(http_method=HttpVerb.GET.value, resource="$") + + +def test_authorizer_response_allow_all_routes_with_context(): + builder = APIGatewayAuthorizerResponse("foo", "us-west-1", "123456789", "fantom", "dev", {"name": "Foo"}) + builder.allow_all_routes() + assert builder.asdict() == { + "principalId": "foo", + "policyDocument": { + "Version": "2012-10-17", + "Statement": [ + { + "Action": "execute-api:Invoke", + "Effect": "Allow", + "Resource": ["arn:aws:execute-api:us-west-1:123456789:fantom/dev/*/*"], + } + ], + }, + "context": {"name": "Foo"}, + } + + +def test_authorizer_response_deny_all_routes(builder: APIGatewayAuthorizerResponse): + builder.deny_all_routes() + assert builder.asdict() == { + "principalId": "foo", + "policyDocument": { + "Version": "2012-10-17", + "Statement": [ + { + "Action": "execute-api:Invoke", + "Effect": "Deny", + "Resource": ["arn:aws:execute-api:us-west-1:123456789:fantom/dev/*/*"], + } + ], + }, + } + + +def test_authorizer_response_allow_route(builder: APIGatewayAuthorizerResponse): + builder.allow_route(http_method=HttpVerb.GET.value, resource="/foo") + assert builder.asdict() == { + "policyDocument": { + "Version": "2012-10-17", + "Statement": [ + { + "Action": "execute-api:Invoke", + "Effect": "Allow", + "Resource": ["arn:aws:execute-api:us-west-1:123456789:fantom/dev/GET/foo"], + } + ], + }, + "principalId": "foo", + } + + +def test_authorizer_response_deny_route(builder: APIGatewayAuthorizerResponse): + builder.deny_route(http_method=HttpVerb.PUT.value, resource="foo") + assert builder.asdict() == { + "principalId": "foo", + "policyDocument": { + "Version": "2012-10-17", + "Statement": [ + { + "Action": "execute-api:Invoke", + "Effect": "Deny", + "Resource": ["arn:aws:execute-api:us-west-1:123456789:fantom/dev/PUT/foo"], + } + ], + }, + } + + +def test_authorizer_response_allow_route_with_conditions(builder: APIGatewayAuthorizerResponse): + condition = {"StringEquals": {"method.request.header.Content-Type": "text/html"}} + builder.allow_route( + http_method=HttpVerb.POST.value, + resource="/foo", + conditions=[condition], + ) + assert builder.asdict() == { + "principalId": "foo", + "policyDocument": { + "Version": "2012-10-17", + "Statement": [ + { + "Action": "execute-api:Invoke", + "Effect": "Allow", + "Resource": ["arn:aws:execute-api:us-west-1:123456789:fantom/dev/POST/foo"], + "Condition": [{"StringEquals": {"method.request.header.Content-Type": "text/html"}}], + } + ], + }, + } + + +def test_authorizer_response_deny_route_with_conditions(builder: APIGatewayAuthorizerResponse): + condition = {"StringEquals": {"method.request.header.Content-Type": "application/json"}} + builder.deny_route(http_method=HttpVerb.POST.value, resource="/foo", conditions=[condition]) + assert builder.asdict() == { + "principalId": "foo", + "policyDocument": { + "Version": "2012-10-17", + "Statement": [ + { + "Action": "execute-api:Invoke", + "Effect": "Deny", + "Resource": ["arn:aws:execute-api:us-west-1:123456789:fantom/dev/POST/foo"], + "Condition": [{"StringEquals": {"method.request.header.Content-Type": "application/json"}}], + } + ], + }, + } diff --git a/tests/functional/event_handler/test_api_gateway.py b/tests/functional/event_handler/test_api_gateway.py index 1272125da8b..683e1aa6c91 100644 --- a/tests/functional/event_handler/test_api_gateway.py +++ b/tests/functional/event_handler/test_api_gateway.py @@ -769,3 +769,76 @@ def get_color() -> Dict: body = response["body"] expected = '{"color": 1, "variations": ["dark", "light"]}' assert expected == body + + +@pytest.mark.parametrize( + "path", + [ + pytest.param("/pay/foo", id="path matched pay prefix"), + pytest.param("/payment/foo", id="path matched payment prefix"), + pytest.param("/foo", id="path does not start with any of the prefixes"), + ], +) +def test_remove_prefix(path: str): + # GIVEN events paths `/pay/foo`, `/payment/foo` or `/foo` + # AND a configured strip_prefixes of `/pay` and `/payment` + app = ApiGatewayResolver(strip_prefixes=["/pay", "/payment"]) + + @app.get("/pay/foo") + def pay_foo(): + raise ValueError("should not be matching") + + @app.get("/foo") + def foo(): + ... + + # WHEN calling handler + response = app({"httpMethod": "GET", "path": path}, None) + + # THEN a route for `/foo` should be found + assert response["statusCode"] == 200 + + +@pytest.mark.parametrize( + "prefix", + [ + pytest.param("/foo", id="String are not supported"), + pytest.param({"/foo"}, id="Sets are not supported"), + pytest.param({"foo": "/foo"}, id="Dicts are not supported"), + pytest.param(tuple("/foo"), id="Tuples are not supported"), + pytest.param([None, 1, "", False], id="List of invalid values"), + ], +) +def test_ignore_invalid(prefix): + # GIVEN an invalid prefix + app = ApiGatewayResolver(strip_prefixes=prefix) + + @app.get("/foo/status") + def foo(): + ... + + # WHEN calling handler + response = app({"httpMethod": "GET", "path": "/foo/status"}, None) + + # THEN a route for `/foo/status` should be found + # so no prefix was stripped from the request path + assert response["statusCode"] == 200 + + +def test_api_gateway_v2_raw_path(): + # GIVEN a Http API V2 proxy type event + # AND a custom stage name "dev" and raw path "/dev/foo" + app = ApiGatewayResolver(proxy_type=ProxyEventType.APIGatewayProxyEventV2) + event = {"rawPath": "/dev/foo", "requestContext": {"http": {"method": "GET"}, "stage": "dev"}} + + @app.get("/foo") + def foo(): + return {} + + # WHEN calling the event handler + # WITH a route "/foo" + result = app(event, {}) + + # THEN process event correctly + assert result["statusCode"] == 200 + assert result["headers"]["Content-Type"] == content_types.APPLICATION_JSON diff --git a/tests/functional/idempotency/conftest.py b/tests/functional/idempotency/conftest.py index 9f61d50d656..e613bb85e60 100644 --- a/tests/functional/idempotency/conftest.py +++ b/tests/functional/idempotency/conftest.py @@ -11,7 +11,7 @@ from botocore.config import Config from jmespath import functions -from aws_lambda_powertools.shared.jmespath_utils import unwrap_event_from_envelope +from aws_lambda_powertools.shared.jmespath_utils import extract_data_from_envelope from aws_lambda_powertools.shared.json_encoder import Encoder from aws_lambda_powertools.utilities.idempotency import DynamoDBPersistenceLayer from aws_lambda_powertools.utilities.idempotency.idempotency import IdempotencyConfig @@ -149,7 +149,7 @@ def hashed_idempotency_key(lambda_apigw_event, default_jmespath, lambda_context) @pytest.fixture def hashed_idempotency_key_with_envelope(lambda_apigw_event): - event = unwrap_event_from_envelope( + event = extract_data_from_envelope( data=lambda_apigw_event, envelope=envelopes.API_GATEWAY_HTTP, jmespath_options={} ) return "test-func#" + hashlib.md5(json.dumps(event).encode()).hexdigest() diff --git a/tests/functional/idempotency/test_idempotency.py b/tests/functional/idempotency/test_idempotency.py index 0ecc84b7f9c..5505a7dc5c9 100644 --- a/tests/functional/idempotency/test_idempotency.py +++ b/tests/functional/idempotency/test_idempotency.py @@ -18,7 +18,7 @@ IdempotencyPersistenceLayerError, IdempotencyValidationError, ) -from aws_lambda_powertools.utilities.idempotency.idempotency import idempotent +from aws_lambda_powertools.utilities.idempotency.idempotency import idempotent, idempotent_function from aws_lambda_powertools.utilities.idempotency.persistence.base import BasePersistenceLayer, DataRecord from aws_lambda_powertools.utilities.validation import envelopes, validator from tests.functional.utils import load_event @@ -221,7 +221,6 @@ def lambda_handler(event, context): stubber.deactivate() -@pytest.mark.skipif(sys.version_info < (3, 8), reason="issue with pytest mock lib for < 3.8") @pytest.mark.parametrize("idempotency_config", [{"use_local_cache": True}], indirect=True) def test_idempotent_lambda_first_execution_cached( idempotency_config: IdempotencyConfig, @@ -255,7 +254,7 @@ def lambda_handler(event, context): retrieve_from_cache_spy.assert_called_once() save_to_cache_spy.assert_called_once() - assert save_to_cache_spy.call_args[0][0].status == "COMPLETED" + assert save_to_cache_spy.call_args[1]["data_record"].status == "COMPLETED" assert persistence_store._cache.get(hashed_idempotency_key).status == "COMPLETED" # This lambda call should not call AWS API @@ -739,7 +738,7 @@ def test_default_no_raise_on_missing_idempotency_key( assert "body" in persistence_store.event_key_jmespath # WHEN getting the hashed idempotency key for an event with no `body` key - hashed_key = persistence_store._get_hashed_idempotency_key({}, lambda_context) + hashed_key = persistence_store._get_hashed_idempotency_key({}) # THEN return the hash of None expected_value = "test-func#" + md5(json.dumps(None).encode()).hexdigest() @@ -760,7 +759,7 @@ def test_raise_on_no_idempotency_key( # WHEN getting the hashed idempotency key for an event with no `body` key with pytest.raises(IdempotencyKeyError) as excinfo: - persistence_store._get_hashed_idempotency_key({}, lambda_context) + persistence_store._get_hashed_idempotency_key({}) # THEN raise IdempotencyKeyError error assert "No data found to create a hashed idempotency_key" in str(excinfo.value) @@ -790,7 +789,7 @@ def test_jmespath_with_powertools_json( } # WHEN calling _get_hashed_idempotency_key - result = persistence_store._get_hashed_idempotency_key(api_gateway_proxy_event, lambda_context) + result = persistence_store._get_hashed_idempotency_key(api_gateway_proxy_event) # THEN the hashed idempotency key should match the extracted values generated hash assert result == "test-func#" + persistence_store._generate_hash(expected_value) @@ -807,7 +806,7 @@ def test_custom_jmespath_function_overrides_builtin_functions( with pytest.raises(jmespath.exceptions.UnknownFunctionError, match="Unknown function: powertools_json()"): # WHEN calling _get_hashed_idempotency_key # THEN raise unknown function - persistence_store._get_hashed_idempotency_key({}, lambda_context) + persistence_store._get_hashed_idempotency_key({}) def test_idempotent_lambda_save_inprogress_error(persistence_store: DynamoDBPersistenceLayer, lambda_context): @@ -885,3 +884,95 @@ def lambda_handler(event, _): result = lambda_handler(mock_event, lambda_context) # THEN we expect the handler to execute successfully assert result == expected_result + + +def test_idempotent_function(): + # Scenario to validate we can use idempotent_function with any function + mock_event = {"data": "value"} + persistence_layer = MockPersistenceLayer("test-func#" + hashlib.md5(json.dumps(mock_event).encode()).hexdigest()) + expected_result = {"message": "Foo"} + + @idempotent_function(persistence_store=persistence_layer, data_keyword_argument="record") + def record_handler(record): + return expected_result + + # WHEN calling the function + result = record_handler(record=mock_event) + # THEN we expect the function to execute successfully + assert result == expected_result + + +def test_idempotent_function_arbitrary_args_kwargs(): + # Scenario to validate we can use idempotent_function with a function + # with an arbitrary number of args and kwargs + mock_event = {"data": "value"} + persistence_layer = MockPersistenceLayer("test-func#" + hashlib.md5(json.dumps(mock_event).encode()).hexdigest()) + expected_result = {"message": "Foo"} + + @idempotent_function(persistence_store=persistence_layer, data_keyword_argument="record") + def record_handler(arg_one, arg_two, record, is_record): + return expected_result + + # WHEN calling the function + result = record_handler("foo", "bar", record=mock_event, is_record=True) + # THEN we expect the function to execute successfully + assert result == expected_result + + +def test_idempotent_function_invalid_data_kwarg(): + mock_event = {"data": "value"} + persistence_layer = MockPersistenceLayer("test-func#" + hashlib.md5(json.dumps(mock_event).encode()).hexdigest()) + expected_result = {"message": "Foo"} + keyword_argument = "payload" + + # GIVEN data_keyword_argument does not match fn signature + @idempotent_function(persistence_store=persistence_layer, data_keyword_argument=keyword_argument) + def record_handler(record): + return expected_result + + # WHEN calling the function + # THEN we expect to receive a Runtime error + with pytest.raises(RuntimeError, match=f"Unable to extract '{keyword_argument}'"): + record_handler(record=mock_event) + + +def test_idempotent_function_arg_instead_of_kwarg(): + mock_event = {"data": "value"} + persistence_layer = MockPersistenceLayer("test-func#" + hashlib.md5(json.dumps(mock_event).encode()).hexdigest()) + expected_result = {"message": "Foo"} + keyword_argument = "record" + + # GIVEN data_keyword_argument matches fn signature + @idempotent_function(persistence_store=persistence_layer, data_keyword_argument=keyword_argument) + def record_handler(record): + return expected_result + + # WHEN calling the function without named argument + # THEN we expect to receive a Runtime error + with pytest.raises(RuntimeError, match=f"Unable to extract '{keyword_argument}'"): + record_handler(mock_event) + + +def test_idempotent_function_and_lambda_handler(lambda_context): + # Scenario to validate we can use both idempotent_function and idempotent decorators + mock_event = {"data": "value"} + persistence_layer = MockPersistenceLayer("test-func#" + hashlib.md5(json.dumps(mock_event).encode()).hexdigest()) + expected_result = {"message": "Foo"} + + @idempotent_function(persistence_store=persistence_layer, data_keyword_argument="record") + def record_handler(record): + return expected_result + + @idempotent(persistence_store=persistence_layer) + def lambda_handler(event, _): + return expected_result + + # WHEN calling the function + fn_result = record_handler(record=mock_event) + + # WHEN calling lambda handler + handler_result = lambda_handler(mock_event, lambda_context) + + # THEN we expect the function and lambda handler to execute successfully + assert fn_result == expected_result + assert handler_result == expected_result diff --git a/tests/functional/test_data_classes.py b/tests/functional/test_data_classes.py index f9bb1fdef73..5514a888e7d 100644 --- a/tests/functional/test_data_classes.py +++ b/tests/functional/test_data_classes.py @@ -22,6 +22,13 @@ SNSEvent, SQSEvent, ) +from aws_lambda_powertools.utilities.data_classes.api_gateway_authorizer_event import ( + APIGatewayAuthorizerEventV2, + APIGatewayAuthorizerRequestEvent, + APIGatewayAuthorizerResponseV2, + APIGatewayAuthorizerTokenEvent, + parse_api_gateway_arn, +) from aws_lambda_powertools.utilities.data_classes.appsync.scalar_types_utils import ( _formatted_time, aws_date, @@ -30,6 +37,10 @@ aws_timestamp, make_id, ) +from aws_lambda_powertools.utilities.data_classes.appsync_authorizer_event import ( + AppSyncAuthorizerEvent, + AppSyncAuthorizerResponse, +) from aws_lambda_powertools.utilities.data_classes.appsync_resolver_event import ( AppSyncIdentityCognito, AppSyncIdentityIAM, @@ -781,6 +792,7 @@ def test_default_api_gateway_proxy_event(): assert identity.user == event["requestContext"]["identity"]["user"] assert identity.user_agent == event["requestContext"]["identity"]["userAgent"] assert identity.user_arn == event["requestContext"]["identity"]["userArn"] + assert identity.client_cert.subject_dn == "www.example.com" assert request_context.path == event["requestContext"]["path"] assert request_context.protocol == event["requestContext"]["protocol"] @@ -847,6 +859,7 @@ def test_api_gateway_proxy_event(): assert identity.user == event["requestContext"]["identity"]["user"] assert identity.user_agent == event["requestContext"]["identity"]["userAgent"] assert identity.user_arn == event["requestContext"]["identity"]["userArn"] + assert identity.client_cert.subject_dn == "www.example.com" assert request_context.path == event["requestContext"]["path"] assert request_context.protocol == event["requestContext"]["protocol"] @@ -871,6 +884,7 @@ def test_api_gateway_proxy_event(): assert request_context.operation_name is None assert identity.api_key is None assert identity.api_key_id is None + assert request_context.identity.client_cert.subject_dn == "www.example.com" def test_api_gateway_proxy_v2_event(): @@ -906,6 +920,7 @@ def test_api_gateway_proxy_v2_event(): assert request_context.stage == event["requestContext"]["stage"] assert request_context.time == event["requestContext"]["time"] assert request_context.time_epoch == event["requestContext"]["timeEpoch"] + assert request_context.authentication.subject_dn == "www.example.com" assert event.body == event["body"] assert event.path_parameters == event["pathParameters"] @@ -1073,6 +1088,7 @@ def test_kinesis_stream_event(): assert kinesis.partition_key == "1" assert kinesis.sequence_number == "49590338271490256608559692538361571095921575989136588898" + assert kinesis.data_as_bytes() == b"Hello, this is a test." assert kinesis.data_as_text() == "Hello, this is a test." @@ -1419,3 +1435,182 @@ def lambda_handler(event: APIGatewayProxyEventV2, _): # WHEN calling the lambda handler lambda_handler({"headers": {"X-Foo": "Foo"}}, None) + + +def test_appsync_authorizer_event(): + event = AppSyncAuthorizerEvent(load_event("appSyncAuthorizerEvent.json")) + + assert event.authorization_token == "BE9DC5E3-D410-4733-AF76-70178092E681" + assert event.authorization_token == event["authorizationToken"] + assert event.request_context.api_id == event["requestContext"]["apiId"] + assert event.request_context.account_id == event["requestContext"]["accountId"] + assert event.request_context.request_id == event["requestContext"]["requestId"] + assert event.request_context.query_string == event["requestContext"]["queryString"] + assert event.request_context.operation_name == event["requestContext"]["operationName"] + assert event.request_context.variables == event["requestContext"]["variables"] + + +def test_appsync_authorizer_response(): + """Check various helper functions for AppSync authorizer response""" + expected = load_event("appSyncAuthorizerResponse.json") + response = AppSyncAuthorizerResponse( + authorize=True, + max_age=15, + resolver_context={"balance": 100, "name": "Foo Man"}, + deny_fields=["Mutation.createEvent"], + ) + assert expected == response.asdict() + + assert {"isAuthorized": False} == AppSyncAuthorizerResponse().asdict() + assert {"isAuthorized": False} == AppSyncAuthorizerResponse(deny_fields=[]).asdict() + assert {"isAuthorized": False} == AppSyncAuthorizerResponse(resolver_context={}).asdict() + assert {"isAuthorized": True} == AppSyncAuthorizerResponse(authorize=True).asdict() + assert {"isAuthorized": False, "ttlOverride": 0} == AppSyncAuthorizerResponse(max_age=0).asdict() + + +def test_api_gateway_authorizer_v2(): + """Check api gateway authorize event format v2.0""" + event = APIGatewayAuthorizerEventV2(load_event("apiGatewayAuthorizerV2Event.json")) + + assert event["version"] == event.version + assert event["version"] == "2.0" + assert event["type"] == event.get_type + assert event["routeArn"] == event.route_arn + assert event.parsed_arn.arn == event.route_arn + assert event["identitySource"] == event.identity_source + assert event["routeKey"] == event.route_key + assert event["rawPath"] == event.raw_path + assert event["rawQueryString"] == event.raw_query_string + assert event["cookies"] == event.cookies + assert event["headers"] == event.headers + assert event["queryStringParameters"] == event.query_string_parameters + assert event["requestContext"]["accountId"] == event.request_context.account_id + assert event["requestContext"]["apiId"] == event.request_context.api_id + expected_client_cert = event["requestContext"]["authentication"]["clientCert"] + assert expected_client_cert["clientCertPem"] == event.request_context.authentication.client_cert_pem + assert expected_client_cert["subjectDN"] == event.request_context.authentication.subject_dn + assert expected_client_cert["issuerDN"] == event.request_context.authentication.issuer_dn + assert expected_client_cert["serialNumber"] == event.request_context.authentication.serial_number + assert expected_client_cert["validity"]["notAfter"] == event.request_context.authentication.validity_not_after + assert expected_client_cert["validity"]["notBefore"] == event.request_context.authentication.validity_not_before + assert event["requestContext"]["domainName"] == event.request_context.domain_name + assert event["requestContext"]["domainPrefix"] == event.request_context.domain_prefix + expected_http = event["requestContext"]["http"] + assert expected_http["method"] == event.request_context.http.method + assert expected_http["path"] == event.request_context.http.path + assert expected_http["protocol"] == event.request_context.http.protocol + assert expected_http["sourceIp"] == event.request_context.http.source_ip + assert expected_http["userAgent"] == event.request_context.http.user_agent + assert event["requestContext"]["requestId"] == event.request_context.request_id + assert event["requestContext"]["routeKey"] == event.request_context.route_key + assert event["requestContext"]["stage"] == event.request_context.stage + assert event["requestContext"]["time"] == event.request_context.time + assert event["requestContext"]["timeEpoch"] == event.request_context.time_epoch + assert event["pathParameters"] == event.path_parameters + assert event["stageVariables"] == event.stage_variables + + assert event.get_header_value("Authorization") == "value" + assert event.get_header_value("authorization") == "value" + assert event.get_header_value("missing") is None + + # Check for optionals + event_optionals = APIGatewayAuthorizerEventV2({"requestContext": {}}) + assert event_optionals.identity_source is None + assert event_optionals.request_context.authentication is None + assert event_optionals.path_parameters is None + assert event_optionals.stage_variables is None + + +def test_api_gateway_authorizer_token_event(): + """Check API Gateway authorizer token event""" + event = APIGatewayAuthorizerTokenEvent(load_event("apiGatewayAuthorizerTokenEvent.json")) + + assert event.authorization_token == event["authorizationToken"] + assert event.method_arn == event["methodArn"] + assert event.parsed_arn.arn == event.method_arn + assert event.get_type == event["type"] + + +def test_api_gateway_authorizer_request_event(): + """Check API Gateway authorizer token event""" + event = APIGatewayAuthorizerRequestEvent(load_event("apiGatewayAuthorizerRequestEvent.json")) + + assert event.version == event["version"] + assert event.get_type == event["type"] + assert event.method_arn == event["methodArn"] + assert event.parsed_arn.arn == event.method_arn + assert event.identity_source == event["identitySource"] + assert event.authorization_token == event["authorizationToken"] + assert event.resource == event["resource"] + assert event.path == event["path"] + assert event.http_method == event["httpMethod"] + assert event.headers == event["headers"] + assert event.get_header_value("accept") == "*/*" + assert event.query_string_parameters == event["queryStringParameters"] + assert event.path_parameters == event["pathParameters"] + assert event.stage_variables == event["stageVariables"] + + assert event.request_context is not None + request_context = event.request_context + assert request_context.account_id == event["requestContext"]["accountId"] + assert request_context.api_id == event["requestContext"]["apiId"] + + assert request_context.domain_name == event["requestContext"]["domainName"] + assert request_context.domain_prefix == event["requestContext"]["domainPrefix"] + assert request_context.extended_request_id == event["requestContext"]["extendedRequestId"] + assert request_context.http_method == event["requestContext"]["httpMethod"] + + identity = request_context.identity + assert identity.access_key == event["requestContext"]["identity"]["accessKey"] + assert identity.account_id == event["requestContext"]["identity"]["accountId"] + assert identity.caller == event["requestContext"]["identity"]["caller"] + assert ( + identity.cognito_authentication_provider == event["requestContext"]["identity"]["cognitoAuthenticationProvider"] + ) + assert identity.cognito_authentication_type == event["requestContext"]["identity"]["cognitoAuthenticationType"] + assert identity.cognito_identity_id == event["requestContext"]["identity"]["cognitoIdentityId"] + assert identity.cognito_identity_pool_id == event["requestContext"]["identity"]["cognitoIdentityPoolId"] + assert identity.principal_org_id == event["requestContext"]["identity"]["principalOrgId"] + assert identity.source_ip == event["requestContext"]["identity"]["sourceIp"] + assert identity.user == event["requestContext"]["identity"]["user"] + assert identity.user_agent == event["requestContext"]["identity"]["userAgent"] + assert identity.user_arn == event["requestContext"]["identity"]["userArn"] + assert identity.client_cert.subject_dn == "www.example.com" + + assert request_context.path == event["requestContext"]["path"] + assert request_context.protocol == event["requestContext"]["protocol"] + assert request_context.request_id == event["requestContext"]["requestId"] + assert request_context.request_time == event["requestContext"]["requestTime"] + assert request_context.request_time_epoch == event["requestContext"]["requestTimeEpoch"] + assert request_context.resource_id == event["requestContext"]["resourceId"] + assert request_context.resource_path == event["requestContext"]["resourcePath"] + assert request_context.stage == event["requestContext"]["stage"] + + +def test_api_gateway_authorizer_simple_response(): + """Check building API Gateway authorizer simple resource""" + assert {"isAuthorized": False} == APIGatewayAuthorizerResponseV2().asdict() + expected_context = {"foo": "value"} + assert {"isAuthorized": True, "context": expected_context} == APIGatewayAuthorizerResponseV2( + authorize=True, + context=expected_context, + ).asdict() + + +def test_api_gateway_route_arn_parser(): + """Check api gateway route or method arn parsing""" + arn = "arn:aws:execute-api:us-east-1:123456789012:abcdef123/test/GET/request" + details = parse_api_gateway_arn(arn) + + assert details.arn == arn + assert details.region == "us-east-1" + assert details.aws_account_id == "123456789012" + assert details.api_id == "abcdef123" + assert details.stage == "test" + assert details.http_method == "GET" + assert details.resource == "request" + + arn = "arn:aws:execute-api:us-west-2:123456789012:ymy8tbxw7b/*/GET" + details = parse_api_gateway_arn(arn) + assert details.resource == "" + assert details.arn == arn + "/"