From a3adb5fe4408cf7fe2d009f84950bc7d7b98553c Mon Sep 17 00:00:00 2001
From: Michael Brewer <michael.brewer@gyft.com>
Date: Thu, 23 Dec 2021 14:03:59 -0800
Subject: [PATCH 1/6] fix(event-handler): allow for @app.not_found() decorator

Changes:
- allow for `@app.not_found()` decorator
- add typing to `strtobool` and add code coverage
---
 .../event_handler/api_gateway.py              |  6 ++++--
 aws_lambda_powertools/shared/functions.py     | 11 +++++-----
 .../event_handler/test_api_gateway.py         | 18 ++++++++++++++++-
 tests/functional/test_shared_functions.py     | 20 ++++++++++++++++++-
 4 files changed, 45 insertions(+), 10 deletions(-)

diff --git a/aws_lambda_powertools/event_handler/api_gateway.py b/aws_lambda_powertools/event_handler/api_gateway.py
index 5bd3bc0b70e..30c13ada6b5 100644
--- a/aws_lambda_powertools/event_handler/api_gateway.py
+++ b/aws_lambda_powertools/event_handler/api_gateway.py
@@ -579,7 +579,7 @@ def _remove_prefix(self, path: str) -> str:
     @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:
+        if not isinstance(prefix, str) or prefix == "":
             return False
 
         return path.startswith(prefix + "/")
@@ -633,7 +633,9 @@ def _call_route(self, route: Route, args: Dict[str, str]) -> ResponseBuilder:
 
             raise
 
-    def not_found(self, func: Callable):
+    def not_found(self, func: Optional[Callable] = None):
+        if func is None:
+            return self.exception_handler(NotFoundError)
         return self.exception_handler(NotFoundError)(func)
 
     def exception_handler(self, exc_class: Type[Exception]):
diff --git a/aws_lambda_powertools/shared/functions.py b/aws_lambda_powertools/shared/functions.py
index 51f55b2cf2f..6a96351f571 100644
--- a/aws_lambda_powertools/shared/functions.py
+++ b/aws_lambda_powertools/shared/functions.py
@@ -1,14 +1,13 @@
 from typing import Any, Optional, Union
 
 
-def strtobool(value):
+def strtobool(value: str) -> bool:
     value = value.lower()
     if value in ("y", "yes", "t", "true", "on", "1"):
-        return 1
-    elif value in ("n", "no", "f", "false", "off", "0"):
-        return 0
-    else:
-        raise ValueError("invalid truth value %r" % (value,))
+        return True
+    if value in ("n", "no", "f", "false", "off", "0"):
+        return False
+    raise ValueError(f"invalid truth value '{value}'")
 
 
 def resolve_truthy_env_var_choice(env: str, choice: Optional[bool] = None) -> bool:
diff --git a/tests/functional/event_handler/test_api_gateway.py b/tests/functional/event_handler/test_api_gateway.py
index 45b1e3f41a4..76ecbc7cdd7 100644
--- a/tests/functional/event_handler/test_api_gateway.py
+++ b/tests/functional/event_handler/test_api_gateway.py
@@ -1142,10 +1142,26 @@ def handle_not_found(exc: NotFoundError) -> Response:
         return Response(status_code=404, content_type=content_types.TEXT_PLAIN, body="I am a teapot!")
 
     # WHEN calling the event handler
-    # AND not route is found
+    # AND no route is found
     result = app(LOAD_GW_EVENT, {})
 
     # THEN call the exception_handler
     assert result["statusCode"] == 404
     assert result["headers"]["Content-Type"] == content_types.TEXT_PLAIN
     assert result["body"] == "I am a teapot!"
+
+
+def test_exception_handler_not_found_alt():
+    # GIVEN a resolver with `@app.not_found()`
+    app = ApiGatewayResolver()
+
+    @app.not_found()
+    def handle_not_found(_) -> Response:
+        return Response(status_code=404, content_type=content_types.APPLICATION_JSON, body="{}")
+
+    # WHEN calling the event handler
+    # AND no route is found
+    result = app(LOAD_GW_EVENT, {})
+
+    # THEN call the @app.not_found() function
+    assert result["statusCode"] == 404
diff --git a/tests/functional/test_shared_functions.py b/tests/functional/test_shared_functions.py
index cc4fd77fbe5..c71b7239739 100644
--- a/tests/functional/test_shared_functions.py
+++ b/tests/functional/test_shared_functions.py
@@ -1,4 +1,6 @@
-from aws_lambda_powertools.shared.functions import resolve_env_var_choice, resolve_truthy_env_var_choice
+import pytest
+
+from aws_lambda_powertools.shared.functions import resolve_env_var_choice, resolve_truthy_env_var_choice, strtobool
 
 
 def test_resolve_env_var_choice_explicit_wins_over_env_var():
@@ -9,3 +11,19 @@ def test_resolve_env_var_choice_explicit_wins_over_env_var():
 def test_resolve_env_var_choice_env_wins_over_absent_explicit():
     assert resolve_truthy_env_var_choice(env="true") == 1
     assert resolve_env_var_choice(env="something") == "something"
+
+
+@pytest.mark.parametrize("true_value", ["y", "yes", "t", "true", "on", "1"])
+def test_strtobool_true(true_value):
+    assert strtobool(true_value)
+
+
+@pytest.mark.parametrize("false_value", ["n", "no", "f", "false", "off", "0"])
+def test_strtobool_false(false_value):
+    assert strtobool(false_value) is False
+
+
+def test_strtobool_value_error():
+    with pytest.raises(ValueError) as exp:
+        strtobool("fail")
+    assert str(exp.value) == "invalid truth value 'fail'"

From 0b8f9576ccbe88e6b7c768795644eccc07ee4324 Mon Sep 17 00:00:00 2001
From: Michael Brewer <michael.brewer@gyft.com>
Date: Thu, 23 Dec 2021 17:04:38 -0800
Subject: [PATCH 2/6] chore: rename to private and add docs

---
 aws_lambda_powertools/shared/functions.py | 14 +++++++++++---
 tests/functional/test_shared_functions.py |  8 ++++----
 2 files changed, 15 insertions(+), 7 deletions(-)

diff --git a/aws_lambda_powertools/shared/functions.py b/aws_lambda_powertools/shared/functions.py
index 6a96351f571..24a0bc830a8 100644
--- a/aws_lambda_powertools/shared/functions.py
+++ b/aws_lambda_powertools/shared/functions.py
@@ -1,13 +1,21 @@
 from typing import Any, Optional, Union
 
 
-def strtobool(value: str) -> bool:
+def _strtobool(value: str) -> bool:
+    """Convert a string representation of truth to True or False.
+
+    True values are 'y', 'yes', 't', 'true', 'on', and '1'; false values
+    are 'n', 'no', 'f', 'false', 'off', and '0'.  Raises ValueError if
+    'value' is anything else.
+
+    > note:: Copied from distutils.util.
+    """
     value = value.lower()
     if value in ("y", "yes", "t", "true", "on", "1"):
         return True
     if value in ("n", "no", "f", "false", "off", "0"):
         return False
-    raise ValueError(f"invalid truth value '{value}'")
+    raise ValueError(f"invalid truth value {value!r}")
 
 
 def resolve_truthy_env_var_choice(env: str, choice: Optional[bool] = None) -> bool:
@@ -27,7 +35,7 @@ def resolve_truthy_env_var_choice(env: str, choice: Optional[bool] = None) -> bo
     choice : str
         resolved choice as either bool or environment value
     """
-    return choice if choice is not None else strtobool(env)
+    return choice if choice is not None else _strtobool(env)
 
 
 def resolve_env_var_choice(env: Any, choice: Optional[Any] = None) -> Union[bool, Any]:
diff --git a/tests/functional/test_shared_functions.py b/tests/functional/test_shared_functions.py
index c71b7239739..db1a672eb37 100644
--- a/tests/functional/test_shared_functions.py
+++ b/tests/functional/test_shared_functions.py
@@ -1,6 +1,6 @@
 import pytest
 
-from aws_lambda_powertools.shared.functions import resolve_env_var_choice, resolve_truthy_env_var_choice, strtobool
+from aws_lambda_powertools.shared.functions import _strtobool, resolve_env_var_choice, resolve_truthy_env_var_choice
 
 
 def test_resolve_env_var_choice_explicit_wins_over_env_var():
@@ -15,15 +15,15 @@ def test_resolve_env_var_choice_env_wins_over_absent_explicit():
 
 @pytest.mark.parametrize("true_value", ["y", "yes", "t", "true", "on", "1"])
 def test_strtobool_true(true_value):
-    assert strtobool(true_value)
+    assert _strtobool(true_value)
 
 
 @pytest.mark.parametrize("false_value", ["n", "no", "f", "false", "off", "0"])
 def test_strtobool_false(false_value):
-    assert strtobool(false_value) is False
+    assert _strtobool(false_value) is False
 
 
 def test_strtobool_value_error():
     with pytest.raises(ValueError) as exp:
-        strtobool("fail")
+        _strtobool("fail")
     assert str(exp.value) == "invalid truth value 'fail'"

From 28384c3a3d35ff5cb8e34d90729c41d99d417341 Mon Sep 17 00:00:00 2001
From: Michael Brewer <michael.brewer@gyft.com>
Date: Sat, 25 Dec 2021 01:50:21 -0800
Subject: [PATCH 3/6] chore: minor docstring typos

---
 .../utilities/data_classes/cognito_user_pool_event.py         | 2 +-
 aws_lambda_powertools/utilities/data_classes/common.py        | 4 ++--
 aws_lambda_powertools/utilities/idempotency/exceptions.py     | 2 +-
 3 files changed, 4 insertions(+), 4 deletions(-)

diff --git a/aws_lambda_powertools/utilities/data_classes/cognito_user_pool_event.py b/aws_lambda_powertools/utilities/data_classes/cognito_user_pool_event.py
index 954d3d15b5f..df2726ee722 100644
--- a/aws_lambda_powertools/utilities/data_classes/cognito_user_pool_event.py
+++ b/aws_lambda_powertools/utilities/data_classes/cognito_user_pool_event.py
@@ -687,7 +687,7 @@ def session(self) -> List[ChallengeResult]:
     @property
     def client_metadata(self) -> Optional[Dict[str, str]]:
         """One or more key-value pairs that you can provide as custom input to the Lambda function that you
-        specify for the create auth challenge trigger.."""
+        specify for the create auth challenge trigger."""
         return self["request"].get("clientMetadata")
 
 
diff --git a/aws_lambda_powertools/utilities/data_classes/common.py b/aws_lambda_powertools/utilities/data_classes/common.py
index 88d1f1d9761..45f6bafc957 100644
--- a/aws_lambda_powertools/utilities/data_classes/common.py
+++ b/aws_lambda_powertools/utilities/data_classes/common.py
@@ -38,7 +38,7 @@ def get_header_value(
     name_lower = name.lower()
 
     return next(
-        # Iterate over the dict and do a case insensitive key comparison
+        # Iterate over the dict and do a case-insensitive key comparison
         (value for key, value in headers.items() if key.lower() == name_lower),
         # Default value is returned if no matches was found
         default_value,
@@ -116,7 +116,7 @@ def get_header_value(
         default_value: str, optional
             Default value if no value was found by name
         case_sensitive: bool
-            Whether to use a case sensitive look up
+            Whether to use a case-sensitive look up
         Returns
         -------
         str, optional
diff --git a/aws_lambda_powertools/utilities/idempotency/exceptions.py b/aws_lambda_powertools/utilities/idempotency/exceptions.py
index 6c7318ebca0..e114ab57e8d 100644
--- a/aws_lambda_powertools/utilities/idempotency/exceptions.py
+++ b/aws_lambda_powertools/utilities/idempotency/exceptions.py
@@ -47,5 +47,5 @@ class IdempotencyPersistenceLayerError(Exception):
 
 class IdempotencyKeyError(Exception):
     """
-    Payload does not contain a idempotent key
+    Payload does not contain an idempotent key
     """

From dcc34b0e1fb0f6a0afbb0c354f7c712e08ba360a Mon Sep 17 00:00:00 2001
From: Michael Brewer <michael.brewer@gyft.com>
Date: Sat, 25 Dec 2021 12:59:12 -0800
Subject: [PATCH 4/6] test(idempotent): add test for sort_key_attr

---
 tests/functional/idempotency/conftest.py      |  5 ++
 .../idempotency/test_idempotency.py           | 46 +++++++++++++++++++
 2 files changed, 51 insertions(+)

diff --git a/tests/functional/idempotency/conftest.py b/tests/functional/idempotency/conftest.py
index a6bcf072a82..017445ab348 100644
--- a/tests/functional/idempotency/conftest.py
+++ b/tests/functional/idempotency/conftest.py
@@ -165,6 +165,11 @@ def persistence_store(config):
     return DynamoDBPersistenceLayer(table_name=TABLE_NAME, boto_config=config)
 
 
+@pytest.fixture
+def persistence_store_compound(config):
+    return DynamoDBPersistenceLayer(table_name=TABLE_NAME, boto_config=config, key_attr="id", sort_key_attr="sk")
+
+
 @pytest.fixture
 def idempotency_config(config, request, default_jmespath):
     return IdempotencyConfig(
diff --git a/tests/functional/idempotency/test_idempotency.py b/tests/functional/idempotency/test_idempotency.py
index 0ed2cfcfb59..0732f1d58b1 100644
--- a/tests/functional/idempotency/test_idempotency.py
+++ b/tests/functional/idempotency/test_idempotency.py
@@ -1148,3 +1148,49 @@ def collect_payment(payment: Payment):
 
     # THEN idempotency key assertion happens at MockPersistenceLayer
     assert result == payment.transaction_id
+
+
+@pytest.mark.parametrize("idempotency_config", [{"use_local_cache": False}], indirect=True)
+def test_idempotent_lambda_compound_already_completed(
+    idempotency_config: IdempotencyConfig,
+    persistence_store_compound: DynamoDBPersistenceLayer,
+    lambda_apigw_event,
+    timestamp_future,
+    hashed_idempotency_key,
+    serialized_lambda_response,
+    deserialized_lambda_response,
+    lambda_context,
+):
+    """
+    Test idempotent decorator having a DynamoDBPersistenceLayer with a compound key
+    """
+
+    stubber = stub.Stubber(persistence_store_compound.table.meta.client)
+    stubber.add_client_error("put_item", "ConditionalCheckFailedException")
+    ddb_response = {
+        "Item": {
+            "id": {"S": "idempotency#"},
+            "sk": {"S": hashed_idempotency_key},
+            "expiration": {"N": timestamp_future},
+            "data": {"S": serialized_lambda_response},
+            "status": {"S": "COMPLETED"},
+        }
+    }
+    expected_params = {
+        "TableName": TABLE_NAME,
+        "Key": {"id": "idempotency#", "sk": hashed_idempotency_key},
+        "ConsistentRead": True,
+    }
+    stubber.add_response("get_item", ddb_response, expected_params)
+
+    stubber.activate()
+
+    @idempotent(config=idempotency_config, persistence_store=persistence_store_compound)
+    def lambda_handler(event, context):
+        raise ValueError
+
+    lambda_resp = lambda_handler(lambda_apigw_event, lambda_context)
+    assert lambda_resp == deserialized_lambda_response
+
+    stubber.assert_no_pending_responses()
+    stubber.deactivate()

From 4e8329d6622c1a575f6a2f62f3b161a357f17ceb Mon Sep 17 00:00:00 2001
From: Michael Brewer <michael.brewer@gyft.com>
Date: Mon, 27 Dec 2021 09:27:45 -0800
Subject: [PATCH 5/6] chore: consistently reference constants env variables

---
 aws_lambda_powertools/utilities/idempotency/idempotency.py | 6 +++---
 1 file changed, 3 insertions(+), 3 deletions(-)

diff --git a/aws_lambda_powertools/utilities/idempotency/idempotency.py b/aws_lambda_powertools/utilities/idempotency/idempotency.py
index 6984cfbbd8e..42b8052fd32 100644
--- a/aws_lambda_powertools/utilities/idempotency/idempotency.py
+++ b/aws_lambda_powertools/utilities/idempotency/idempotency.py
@@ -7,7 +7,7 @@
 from typing import Any, Callable, Dict, Optional, cast
 
 from aws_lambda_powertools.middleware_factory import lambda_handler_decorator
-from aws_lambda_powertools.shared.constants import IDEMPOTENCY_DISABLED_ENV
+from aws_lambda_powertools.shared import constants
 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
@@ -58,7 +58,7 @@ def idempotent(
         >>>     return {"StatusCode": 200}
     """
 
-    if os.getenv(IDEMPOTENCY_DISABLED_ENV):
+    if os.getenv(constants.IDEMPOTENCY_DISABLED_ENV):
         return handler(event, context)
 
     config = config or IdempotencyConfig()
@@ -127,7 +127,7 @@ def process_order(customer_id: str, order: dict, **kwargs):
 
     @functools.wraps(function)
     def decorate(*args, **kwargs):
-        if os.getenv(IDEMPOTENCY_DISABLED_ENV):
+        if os.getenv(constants.IDEMPOTENCY_DISABLED_ENV):
             return function(*args, **kwargs)
 
         payload = kwargs.get(data_keyword_argument)

From f5685d004f2150e91fddc0549464116634a82f9a Mon Sep 17 00:00:00 2001
From: Michael Brewer <michael.brewer@gyft.com>
Date: Wed, 29 Dec 2021 09:56:03 -0800
Subject: [PATCH 6/6] refactor: revert name back to strtobool

---
 aws_lambda_powertools/shared/functions.py | 4 ++--
 tests/functional/test_shared_functions.py | 8 ++++----
 2 files changed, 6 insertions(+), 6 deletions(-)

diff --git a/aws_lambda_powertools/shared/functions.py b/aws_lambda_powertools/shared/functions.py
index 24a0bc830a8..11c4e4ce77c 100644
--- a/aws_lambda_powertools/shared/functions.py
+++ b/aws_lambda_powertools/shared/functions.py
@@ -1,7 +1,7 @@
 from typing import Any, Optional, Union
 
 
-def _strtobool(value: str) -> bool:
+def strtobool(value: str) -> bool:
     """Convert a string representation of truth to True or False.
 
     True values are 'y', 'yes', 't', 'true', 'on', and '1'; false values
@@ -35,7 +35,7 @@ def resolve_truthy_env_var_choice(env: str, choice: Optional[bool] = None) -> bo
     choice : str
         resolved choice as either bool or environment value
     """
-    return choice if choice is not None else _strtobool(env)
+    return choice if choice is not None else strtobool(env)
 
 
 def resolve_env_var_choice(env: Any, choice: Optional[Any] = None) -> Union[bool, Any]:
diff --git a/tests/functional/test_shared_functions.py b/tests/functional/test_shared_functions.py
index db1a672eb37..c71b7239739 100644
--- a/tests/functional/test_shared_functions.py
+++ b/tests/functional/test_shared_functions.py
@@ -1,6 +1,6 @@
 import pytest
 
-from aws_lambda_powertools.shared.functions import _strtobool, resolve_env_var_choice, resolve_truthy_env_var_choice
+from aws_lambda_powertools.shared.functions import resolve_env_var_choice, resolve_truthy_env_var_choice, strtobool
 
 
 def test_resolve_env_var_choice_explicit_wins_over_env_var():
@@ -15,15 +15,15 @@ def test_resolve_env_var_choice_env_wins_over_absent_explicit():
 
 @pytest.mark.parametrize("true_value", ["y", "yes", "t", "true", "on", "1"])
 def test_strtobool_true(true_value):
-    assert _strtobool(true_value)
+    assert strtobool(true_value)
 
 
 @pytest.mark.parametrize("false_value", ["n", "no", "f", "false", "off", "0"])
 def test_strtobool_false(false_value):
-    assert _strtobool(false_value) is False
+    assert strtobool(false_value) is False
 
 
 def test_strtobool_value_error():
     with pytest.raises(ValueError) as exp:
-        _strtobool("fail")
+        strtobool("fail")
     assert str(exp.value) == "invalid truth value 'fail'"