Skip to content

Commit

Permalink
fix(apigateway): support @app.not_found() syntax & housekeeping (#926)
Browse files Browse the repository at this point in the history
  • Loading branch information
Michael Brewer authored Dec 30, 2021
1 parent cced6c4 commit bdf307b
Show file tree
Hide file tree
Showing 10 changed files with 111 additions and 17 deletions.
6 changes: 4 additions & 2 deletions aws_lambda_powertools/event_handler/api_gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 + "/")
Expand Down Expand Up @@ -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]):
Expand Down
19 changes: 13 additions & 6 deletions aws_lambda_powertools/shared/functions.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,21 @@
from typing import Any, Optional, Union


def strtobool(value):
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 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!r}")


def resolve_truthy_env_var_choice(env: str, choice: Optional[bool] = None) -> bool:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")


Expand Down
4 changes: 2 additions & 2 deletions aws_lambda_powertools/utilities/data_classes/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion aws_lambda_powertools/utilities/idempotency/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,5 +47,5 @@ class IdempotencyPersistenceLayerError(Exception):

class IdempotencyKeyError(Exception):
"""
Payload does not contain a idempotent key
Payload does not contain an idempotent key
"""
6 changes: 3 additions & 3 deletions aws_lambda_powertools/utilities/idempotency/idempotency.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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)
Expand Down
18 changes: 17 additions & 1 deletion tests/functional/event_handler/test_api_gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
5 changes: 5 additions & 0 deletions tests/functional/idempotency/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
46 changes: 46 additions & 0 deletions tests/functional/idempotency/test_idempotency.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
20 changes: 19 additions & 1 deletion tests/functional/test_shared_functions.py
Original file line number Diff line number Diff line change
@@ -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():
Expand All @@ -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'"

0 comments on commit bdf307b

Please sign in to comment.