Skip to content

Commit d0bd984

Browse files
committed
* 'develop' of https://github.com/awslabs/aws-lambda-powertools-python: feat(feature-flags): improve "IN/NOT_IN"; new rule actions (aws-powertools#710) feat(idempotency): makes customers unit testing easier (aws-powertools#719) feat(feature-flags): get_raw_configuration property in Store (aws-powertools#720)
2 parents 3493789 + c837e0a commit d0bd984

File tree

10 files changed

+451
-9
lines changed

10 files changed

+451
-9
lines changed

Diff for: aws_lambda_powertools/shared/constants.py

+2
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,5 @@
2121

2222
XRAY_SDK_MODULE: str = "aws_xray_sdk"
2323
XRAY_SDK_CORE_MODULE: str = "aws_xray_sdk.core"
24+
25+
IDEMPOTENCY_DISABLED_ENV: str = "POWERTOOLS_IDEMPOTENCY_DISABLED"

Diff for: aws_lambda_powertools/utilities/feature_flags/feature_flags.py

+4
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,10 @@ def _match_by_action(action: str, condition_value: Any, context_value: Any) -> b
4848
schema.RuleAction.ENDSWITH.value: lambda a, b: a.endswith(b),
4949
schema.RuleAction.IN.value: lambda a, b: a in b,
5050
schema.RuleAction.NOT_IN.value: lambda a, b: a not in b,
51+
schema.RuleAction.KEY_IN_VALUE.value: lambda a, b: a in b,
52+
schema.RuleAction.KEY_NOT_IN_VALUE.value: lambda a, b: a not in b,
53+
schema.RuleAction.VALUE_IN_KEY.value: lambda a, b: b in a,
54+
schema.RuleAction.VALUE_NOT_IN_KEY.value: lambda a, b: b not in a,
5155
}
5256

5357
try:

Diff for: aws_lambda_powertools/utilities/feature_flags/schema.py

+7-1
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@ class RuleAction(str, Enum):
2222
ENDSWITH = "ENDSWITH"
2323
IN = "IN"
2424
NOT_IN = "NOT_IN"
25+
KEY_IN_VALUE = "KEY_IN_VALUE"
26+
KEY_NOT_IN_VALUE = "KEY_NOT_IN_VALUE"
27+
VALUE_IN_KEY = "VALUE_IN_KEY"
28+
VALUE_NOT_IN_KEY = "VALUE_NOT_IN_KEY"
2529

2630

2731
class SchemaValidator(BaseValidator):
@@ -80,7 +84,9 @@ class SchemaValidator(BaseValidator):
8084
The value MUST contain the following members:
8185
8286
* **action**: `str`. Operation to perform to match a key and value.
83-
The value MUST be either EQUALS, STARTSWITH, ENDSWITH, IN, NOT_IN
87+
The value MUST be either EQUALS, STARTSWITH, ENDSWITH,
88+
KEY_IN_VALUE KEY_NOT_IN_VALUE VALUE_IN_KEY VALUE_NOT_IN_KEY
89+
8490
* **key**: `str`. Key in given context to perform operation
8591
* **value**: `Any`. Value in given context that should match action operation.
8692

Diff for: aws_lambda_powertools/utilities/idempotency/idempotency.py

+8
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@
33
"""
44
import functools
55
import logging
6+
import os
67
from typing import Any, Callable, Dict, Optional, cast
78

89
from aws_lambda_powertools.middleware_factory import lambda_handler_decorator
10+
from aws_lambda_powertools.shared.constants import IDEMPOTENCY_DISABLED_ENV
911
from aws_lambda_powertools.shared.types import AnyCallableT
1012
from aws_lambda_powertools.utilities.idempotency.base import IdempotencyHandler
1113
from aws_lambda_powertools.utilities.idempotency.config import IdempotencyConfig
@@ -56,6 +58,9 @@ def idempotent(
5658
>>> return {"StatusCode": 200}
5759
"""
5860

61+
if os.getenv(IDEMPOTENCY_DISABLED_ENV):
62+
return handler(event, context)
63+
5964
config = config or IdempotencyConfig()
6065
args = event, context
6166
idempotency_handler = IdempotencyHandler(
@@ -122,6 +127,9 @@ def process_order(customer_id: str, order: dict, **kwargs):
122127

123128
@functools.wraps(function)
124129
def decorate(*args, **kwargs):
130+
if os.getenv(IDEMPOTENCY_DISABLED_ENV):
131+
return function(*args, **kwargs)
132+
125133
payload = kwargs.get(data_keyword_argument)
126134

127135
if payload is None:

Diff for: aws_lambda_powertools/utilities/idempotency/persistence/dynamodb.py

+24-5
Original file line numberDiff line numberDiff line change
@@ -62,18 +62,37 @@ def __init__(
6262
>>> return {"StatusCode": 200}
6363
"""
6464

65-
boto_config = boto_config or Config()
66-
session = boto3_session or boto3.session.Session()
67-
self._ddb_resource = session.resource("dynamodb", config=boto_config)
65+
self._boto_config = boto_config or Config()
66+
self._boto3_session = boto3_session or boto3.session.Session()
67+
68+
self._table = None
6869
self.table_name = table_name
69-
self.table = self._ddb_resource.Table(self.table_name)
7070
self.key_attr = key_attr
7171
self.expiry_attr = expiry_attr
7272
self.status_attr = status_attr
7373
self.data_attr = data_attr
7474
self.validation_key_attr = validation_key_attr
7575
super(DynamoDBPersistenceLayer, self).__init__()
7676

77+
@property
78+
def table(self):
79+
"""
80+
Caching property to store boto3 dynamodb Table resource
81+
82+
"""
83+
if self._table:
84+
return self._table
85+
ddb_resource = self._boto3_session.resource("dynamodb", config=self._boto_config)
86+
self._table = ddb_resource.Table(self.table_name)
87+
return self._table
88+
89+
@table.setter
90+
def table(self, table):
91+
"""
92+
Allow table instance variable to be set directly, primarily for use in tests
93+
"""
94+
self._table = table
95+
7796
def _item_to_data_record(self, item: Dict[str, Any]) -> DataRecord:
7897
"""
7998
Translate raw item records from DynamoDB to DataRecord
@@ -125,7 +144,7 @@ def _put_record(self, data_record: DataRecord) -> None:
125144
ExpressionAttributeNames={"#id": self.key_attr, "#now": self.expiry_attr},
126145
ExpressionAttributeValues={":now": int(now.timestamp())},
127146
)
128-
except self._ddb_resource.meta.client.exceptions.ConditionalCheckFailedException:
147+
except self.table.meta.client.exceptions.ConditionalCheckFailedException:
129148
logger.debug(f"Failed to put record for already existing idempotency key: {data_record.idempotency_key}")
130149
raise IdempotencyItemAlreadyExistsError
131150

Diff for: docs/utilities/feature_flags.md

+43-3
Original file line numberDiff line numberDiff line change
@@ -366,7 +366,7 @@ You can use `get_enabled_features` method for scenarios where you need a list of
366366
"when_match": true,
367367
"conditions": [
368368
{
369-
"action": "IN",
369+
"action": "KEY_IN_VALUE",
370370
"key": "CloudFront-Viewer-Country",
371371
"value": ["NL", "IE", "UK", "PL", "PT"]
372372
}
@@ -450,9 +450,20 @@ The `conditions` block is a list of conditions that contain `action`, `key`, and
450450
}
451451
```
452452

453-
The `action` configuration can have 5 different values: `EQUALS`, `STARTSWITH`, `ENDSWITH`, `IN`, `NOT_IN`.
453+
The `action` configuration can have the following values, where the expressions **`a`** is the `key` and **`b`** is the `value` above:
454454

455-
The `key` and `value` will be compared to the input from the context parameter.
455+
Action | Equivalent expression
456+
------------------------------------------------- | ---------------------------------------------------------------------------------
457+
**EQUALS** | `lambda a, b: a == b`
458+
**STARTSWITH** | `lambda a, b: a.startswith(b)`
459+
**ENDSWITH** | `lambda a, b: a.endswith(b)`
460+
**KEY_IN_VALUE** | `lambda a, b: a in b`
461+
**KEY_NOT_IN_VALUE** | `lambda a, b: a not in b`
462+
**VALUE_IN_KEY** | `lambda a, b: b in a`
463+
**VALUE_NOT_IN_KEY** | `lambda a, b: b not in a`
464+
465+
466+
!!! info "The `**key**` and `**value**` will be compared to the input from the `**context**` parameter."
456467

457468
**For multiple conditions**, we will evaluate the list of conditions as a logical `AND`, so all conditions needs to match to return `when_match` value.
458469

@@ -529,6 +540,27 @@ For this to work, you need to use a JMESPath expression via the `envelope` param
529540
}
530541
```
531542

543+
### Getting fetched configuration
544+
545+
You can access the configuration fetched from the store via `get_raw_configuration` property within the store instance.
546+
547+
=== "app.py"
548+
549+
```python hl_lines="12"
550+
from aws_lambda_powertools.utilities.feature_flags import FeatureFlags, AppConfigStore
551+
552+
app_config = AppConfigStore(
553+
environment="dev",
554+
application="product-catalogue",
555+
name="configuration",
556+
envelope = "feature_flags"
557+
)
558+
559+
feature_flags = FeatureFlags(store=app_config)
560+
561+
config = app_config.get_raw_configuration
562+
```
563+
532564
### Built-in store provider
533565

534566
!!! info "For GA, you'll be able to bring your own store."
@@ -650,3 +682,11 @@ Method | When to use | Requires new deployment on changes | Supported services
650682
**[Environment variables](https://docs.aws.amazon.com/lambda/latest/dg/configuration-envvars.html){target="_blank"}** | Simple configuration that will rarely if ever change, because changing it requires a Lambda function deployment. | Yes | Lambda
651683
**[Parameters utility](parameters.md)** | Access to secrets, or fetch parameters in different formats from AWS System Manager Parameter Store or Amazon DynamoDB. | No | Parameter Store, DynamoDB, Secrets Manager, AppConfig
652684
**Feature flags utility** | Rule engine to define when one or multiple features should be enabled depending on the input. | No | AppConfig
685+
686+
687+
## Deprecation list when GA
688+
689+
Breaking change | Recommendation
690+
------------------------------------------------- | ---------------------------------------------------------------------------------
691+
`IN` RuleAction | Use `KEY_IN_VALUE` instead
692+
`NOT_IN` RuleAction | Use `KEY_NOT_IN_VALUE` instead

Diff for: docs/utilities/idempotency.md

+117
Original file line numberDiff line numberDiff line change
@@ -765,6 +765,123 @@ The idempotency utility can be used with the `validator` decorator. Ensure that
765765
!!! tip "JMESPath Powertools functions are also available"
766766
Built-in functions known in the validation utility like `powertools_json`, `powertools_base64`, `powertools_base64_gzip` are also available to use in this utility.
767767

768+
769+
## Testing your code
770+
771+
The idempotency utility provides several routes to test your code.
772+
773+
### Disabling the idempotency utility
774+
When testing your code, you may wish to disable the idempotency logic altogether and focus on testing your business logic. To do this, you can set the environment variable `POWERTOOLS_IDEMPOTENCY_DISABLED`
775+
with a truthy value. If you prefer setting this for specific tests, and are using Pytest, you can use [monkeypatch](https://docs.pytest.org/en/latest/monkeypatch.html) fixture:
776+
777+
=== "tests.py"
778+
779+
```python hl_lines="2 3"
780+
def test_idempotent_lambda_handler(monkeypatch):
781+
# Set POWERTOOLS_IDEMPOTENCY_DISABLED before calling decorated functions
782+
monkeypatch.setenv("POWERTOOLS_IDEMPOTENCY_DISABLED", 1)
783+
784+
result = handler()
785+
...
786+
```
787+
=== "app.py"
788+
789+
```python
790+
from aws_lambda_powertools.utilities.idempotency import (
791+
DynamoDBPersistenceLayer, idempotent
792+
)
793+
794+
persistence_layer = DynamoDBPersistenceLayer(table_name="idempotency")
795+
796+
@idempotent(persistence_store=persistence_layer)
797+
def handler(event, context):
798+
print('expensive operation')
799+
return {
800+
"payment_id": 12345,
801+
"message": "success",
802+
"statusCode": 200,
803+
}
804+
```
805+
806+
### Testing with DynamoDB Local
807+
808+
To test with [DynamoDB Local](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/DynamoDBLocal.DownloadingAndRunning.html), you can replace the `Table` resource used by the persistence layer with one you create inside your tests. This allows you to set the endpoint_url.
809+
810+
=== "tests.py"
811+
812+
```python hl_lines="6 7 8"
813+
import boto3
814+
815+
import app
816+
817+
def test_idempotent_lambda():
818+
# Create our own Table resource using the endpoint for our DynamoDB Local instance
819+
resource = boto3.resource("dynamodb", endpoint_url='http://localhost:8000')
820+
table = resource.Table(app.persistence_layer.table_name)
821+
app.persistence_layer.table = table
822+
823+
result = app.handler({'testkey': 'testvalue'}, {})
824+
assert result['payment_id'] == 12345
825+
```
826+
827+
=== "app.py"
828+
829+
```python
830+
from aws_lambda_powertools.utilities.idempotency import (
831+
DynamoDBPersistenceLayer, idempotent
832+
)
833+
834+
persistence_layer = DynamoDBPersistenceLayer(table_name="idempotency")
835+
836+
@idempotent(persistence_store=persistence_layer)
837+
def handler(event, context):
838+
print('expensive operation')
839+
return {
840+
"payment_id": 12345,
841+
"message": "success",
842+
"statusCode": 200,
843+
}
844+
```
845+
846+
### How do I mock all DynamoDB I/O operations
847+
848+
The idempotency utility lazily creates the dynamodb [Table](https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/dynamodb.html#table) which it uses to access DynamoDB.
849+
This means it is possible to pass a mocked Table resource, or stub various methods.
850+
851+
=== "tests.py"
852+
853+
```python hl_lines="6 7 8 9"
854+
from unittest.mock import MagicMock
855+
856+
import app
857+
858+
def test_idempotent_lambda():
859+
table = MagicMock()
860+
app.persistence_layer.table = table
861+
result = app.handler({'testkey': 'testvalue'}, {})
862+
table.put_item.assert_called()
863+
...
864+
```
865+
866+
=== "app.py"
867+
868+
```python
869+
from aws_lambda_powertools.utilities.idempotency import (
870+
DynamoDBPersistenceLayer, idempotent
871+
)
872+
873+
persistence_layer = DynamoDBPersistenceLayer(table_name="idempotency")
874+
875+
@idempotent(persistence_store=persistence_layer)
876+
def handler(event, context):
877+
print('expensive operation')
878+
return {
879+
"payment_id": 12345,
880+
"message": "success",
881+
"statusCode": 200,
882+
}
883+
```
884+
768885
## Extra resources
769886

770887
If you're interested in a deep dive on how Amazon uses idempotency when building our APIs, check out

0 commit comments

Comments
 (0)