")
+ def get_user(user_id: int) -> UserModel:
+ return UserModel(name=f"User{user_id}", age=user_id + 20, email=f"user{user_id}@example.com")
+
+ # WHEN calling the user route
+ gw_event["path"] = "/user/123"
+ gw_event["httpMethod"] = "GET"
+
+ # THEN it should return 200 with validated response
+ result = app(gw_event, {})
+
+ assert result["statusCode"] == 200
+ response_body = json.loads(result["body"])
+ assert response_body["name"] == "User123"
+ assert response_body["age"] == 143
+ assert response_body["email"] == "user123@example.com"
+
+
+def test_field_discriminator_validation(gw_event):
+ """Test that Pydantic Field discriminator works with event_handler validation"""
+ app = APIGatewayRestResolver(enable_validation=True)
+
+ class FooAction(BaseModel):
+ action: Literal["foo"]
+ foo_data: str
+
+ class BarAction(BaseModel):
+ action: Literal["bar"]
+ bar_data: int
+
+ action_type = Annotated[Union[FooAction, BarAction], Field(discriminator="action")]
+
+ @app.post("/actions")
+ def create_action(action: Annotated[action_type, Body()]):
+ return {"received_action": action.action, "data": action.model_dump()}
+
+ gw_event["path"] = "/actions"
+ gw_event["httpMethod"] = "POST"
+ gw_event["headers"]["content-type"] = "application/json"
+ gw_event["body"] = '{"action": "foo", "foo_data": "test"}'
+
+ result = app(gw_event, {})
+ assert result["statusCode"] == 200
+
+ response_body = json.loads(result["body"])
+ assert response_body["received_action"] == "foo"
+ assert response_body["data"]["action"] == "foo"
+ assert response_body["data"]["foo_data"] == "test"
+
+ gw_event["body"] = '{"action": "bar", "bar_data": 123}'
+
+ result = app(gw_event, {})
+ assert result["statusCode"] == 200
+
+ response_body = json.loads(result["body"])
+ assert response_body["received_action"] == "bar"
+ assert response_body["data"]["action"] == "bar"
+ assert response_body["data"]["bar_data"] == 123
+
+ gw_event["body"] = '{"action": "invalid", "some_data": "test"}'
+
+ result = app(gw_event, {})
+ assert result["statusCode"] == 422
+
+
+def test_field_annotation_with_all_param_types(gw_event):
+ """A reusable Annotated type carrying a Pydantic Field works with every parameter location."""
+ app = APIGatewayRestResolver(enable_validation=True)
+
+ # Reusable annotated type, the same kind you'd use inside a model
+ str_field = Annotated[str, Field()]
+
+ @app.get("/header")
+ def get_header(h: Annotated[str_field, Header()]):
+ return {"value": h}
+
+ @app.get("/path/")
+ def get_path(p: Annotated[str_field, Path()]):
+ return {"value": p}
+
+ @app.get("/query")
+ def get_query(q: Annotated[str_field, Query()]):
+ return {"value": q}
+
+ @app.post("/body")
+ def post_body(b: Annotated[str_field, Body()]):
+ return {"value": b}
+
+ del gw_event["multiValueHeaders"]
+ del gw_event["multiValueQueryStringParameters"]
+
+ # Header
+ gw_event["path"] = "/header"
+ gw_event["httpMethod"] = "GET"
+ gw_event["headers"] = {"h": "test"}
+ assert app(gw_event, {})["statusCode"] == 200
+
+ # Path
+ gw_event["path"] = "/path/test"
+ gw_event["pathParameters"] = {"p": "test"}
+ assert app(gw_event, {})["statusCode"] == 200
+
+ # Query
+ gw_event["path"] = "/query"
+ gw_event["pathParameters"] = None
+ gw_event["queryStringParameters"] = {"q": "test"}
+ assert app(gw_event, {})["statusCode"] == 200
+
+ # Body
+ gw_event["path"] = "/body"
+ gw_event["httpMethod"] = "POST"
+ gw_event["headers"]["content-type"] = "application/json"
+ gw_event["body"] = '"test"'
+ assert app(gw_event, {})["statusCode"] == 200
+
+
+def test_field_constraints_apply_with_param_type(gw_event):
+ """Constraints declared on a Field are enforced when paired with a location marker."""
+ app = APIGatewayRestResolver(enable_validation=True)
+
+ @app.get("/items")
+ def get_items(quantity: Annotated[int, Field(gt=0), Query()]):
+ return {"quantity": quantity}
+
+ gw_event["path"] = "/items"
+ gw_event["httpMethod"] = "GET"
+
+ # Passes the gt=0 constraint
+ gw_event["queryStringParameters"] = {"quantity": "5"}
+ assert app(gw_event, {})["statusCode"] == 200
+
+ # Violates gt=0
+ gw_event["queryStringParameters"] = {"quantity": "-1"}
+ assert app(gw_event, {})["statusCode"] == 422
+
+
+def test_validate_pydantic_query_params_with_config_dict_and_validators(gw_event):
+ """Test that Pydantic models with ConfigDict, aliases, and validators work correctly"""
+
+ del gw_event["multiValueHeaders"]
+ del gw_event["multiValueQueryStringParameters"]
+
+ app = APIGatewayRestResolver(enable_validation=True)
+
+ def _validate_powertools(value: str) -> str:
+ if not value.startswith("Powertools"):
+ raise ValueError("Full name must start with 'Powertools'")
+ return value
+
+ class QuerySimple(BaseModel):
+ full_name: Annotated[str, StringConstraints(min_length=5), AfterValidator(_validate_powertools)]
+ next_token: Base64UrlStr
+ search_id: str
+
+ @app.get("/query-model-simple")
+ def query_model(params: Annotated[QuerySimple, Query()]) -> Dict[str, Any]:
+ return {
+ "fullName": params.full_name,
+ "nextToken": params.next_token,
+ "searchId": params.search_id,
+ }
+
+ class QueryAdvanced(BaseModel):
+ full_name: Annotated[str, StringConstraints(min_length=5)]
+ next_token: str
+ search_id: Annotated[str, Field(alias="id")] # Using str instead of UUID4 for simpler testing
+
+ model_config = ConfigDict(
+ alias_generator=alias_generators.to_camel,
+ validate_by_alias=True,
+ validate_by_name=True,
+ serialize_by_alias=True,
+ )
+
+ @app.get("/query-model-advanced")
+ def query_model_advanced(params: Annotated[QueryAdvanced, Query()]) -> Dict[str, Any]:
+ return params.model_dump()
+
+ # Test QuerySimple with validators
+ gw_event["path"] = "/query-model-simple"
+ gw_event["queryStringParameters"] = {
+ "full_name": "Powertools Lambda",
+ "next_token": "dGVzdA==", # base64url encoded "test"
+ "search_id": "search-123",
+ }
+
+ result = app(gw_event, {})
+ assert result["statusCode"] == 200
+
+ body = json.loads(result["body"])
+ assert body["fullName"] == "Powertools Lambda"
+ assert body["nextToken"] == "test"
+ assert body["searchId"] == "search-123"
+
+ # Test QuerySimple validation error (name doesn't start with "Powertools")
+ gw_event["queryStringParameters"] = {
+ "full_name": "Lambda Powertools",
+ "next_token": "dGVzdA==",
+ "search_id": "search-123",
+ }
+
+ result = app(gw_event, {})
+ assert result["statusCode"] == 422
+
+ body = json.loads(result["body"])
+ assert "detail" in body
+ errors = body["detail"]
+
+ # Should have validation error for full_name with proper location
+ full_name_error = next((e for e in errors if "full_name" in e["loc"]), None)
+
+ assert full_name_error is not None, "Should have error for full_name field"
+
+ # Check error details for full_name
+ assert full_name_error["loc"] == ["query", "params", "full_name"]
+ assert full_name_error["type"] == "value_error"
+
+ # Test QueryAdvanced with ConfigDict and alias_generator
+ gw_event["path"] = "/query-model-advanced"
+ gw_event["queryStringParameters"] = {
+ "fullName": "Advanced Test", # camelCase from alias_generator
+ "nextToken": "dGVzdA==", # camelCase from alias_generator
+ "id": "search-456", # explicit alias
+ }
+
+ result = app(gw_event, {})
+ assert result["statusCode"] == 200
+
+ body = json.loads(result["body"])
+ # Should return with camelCase keys due to serialize_by_alias=True
+ assert body["fullName"] == "Advanced Test"
+ assert body["nextToken"] == "dGVzdA=="
+ assert body["id"] == "search-456"
+
+ # Test QueryAdvanced with snake_case field names due to validate_by_name=True
+ gw_event["queryStringParameters"] = {
+ "full_name": "Snake Case Test", # snake_case field name
+ "next_token": "dGVzdA==", # snake_case field name
+ "search_id": "search-789", # snake_case field name
+ }
+
+ gw_event["path"] = "/query-model-advanced"
+ result = app(gw_event, {})
+ assert result["statusCode"] == 200
+
+ body = json.loads(result["body"])
+ assert body["fullName"] == "Snake Case Test"
+ assert body["nextToken"] == "dGVzdA=="
+ assert body["id"] == "search-789"
+
+ # Test QueryAdvanced validation error (full_name too short)
+ gw_event["queryStringParameters"] = {
+ "fullName": "Bad", # Too short (min_length=5)
+ "nextToken": "dGVzdA==",
+ "id": "search-456",
+ }
+
+ result = app(gw_event, {})
+ assert result["statusCode"] == 422
+
+ body = json.loads(result["body"])
+ assert "detail" in body
+ errors = body["detail"]
+
+ # Should have validation error for full_name with proper location
+ full_name_error = next((e for e in errors if "full_name" in e["loc"] or "fullName" in e["loc"]), None)
+ assert full_name_error is not None
+ assert full_name_error["type"] == "string_too_short"
+
+
+def test_validation_query_string_with_fully_encoded_datetime_alb_resolver():
+ # GIVEN a ALBResolver with validation enabled,
+ # and an event with a fully url-encoded datetime
+ # as a query string parameter
+ app = ALBResolver(enable_validation=True, decode_query_parameters=True)
+ raw_event = load_event("albEvent.json")
+ raw_event["path"] = "/users"
+ # Fully encoded: "2025-12-20T16:56:02.032000" -> "2025-12-20T16%3A56%3A02.032000"
+ # With spaces or special chars: "2025-12-20 16:56:02" -> "2025-12-20%2016%3A56%3A02"
+ raw_event["queryStringParameters"] = {"query_dt": "2025-12-20T16%3A56%3A02.032000"}
+
+ @app.get("/users")
+ def handler(query_dt: datetime.datetime):
+ return {"received": query_dt.isoformat()}
+
+ result = app(raw_event, {})
+ assert result["statusCode"] == 200
+ body = json.loads(result["body"])
+ assert body["received"] == "2025-12-20T16:56:02.032000"
+
+
+def test_validation_query_string_with_encoded_key_and_value_alb_resolver():
+ # GIVEN a ALBResolver with validation enabled,
+ # and an event with url-encoded key AND value
+ app = ALBResolver(enable_validation=True, decode_query_parameters=True)
+ raw_event = load_event("albEvent.json")
+ raw_event["path"] = "/search"
+ # Key: "search query" -> "search%20query"
+ # Value: "hello world" -> "hello%20world"
+ raw_event["queryStringParameters"] = {"search%20query": "hello%20world"}
+
+ @app.get("/search")
+ def handler(search_query: Annotated[str, Query(alias="search query")]):
+ return {"result": search_query}
+
+ result = app(raw_event, {})
+ assert result["statusCode"] == 200
+ body = json.loads(result["body"])
+ assert body["result"] == "hello world"
+
+
+def test_validation_without_decode_query_parameters_alb_resolver():
+ # GIVEN a ALBResolver WITHOUT decode_query_parameters (default behavior)
+ app = ALBResolver(enable_validation=True)
+ raw_event = load_event("albEvent.json")
+ raw_event["path"] = "/users"
+ raw_event["queryStringParameters"] = {"query_dt": "2025-12-20T16%3A56%3A02.032000"}
+
+ @app.get("/users")
+ def handler(query_dt: datetime.datetime):
+ return None
+
+ # THEN validation should fail because the encoded string is not a valid datetime
+ result = app(raw_event, {})
+ assert result["statusCode"] == 422
+
+
+def test_validate_union_single_or_list_body_with_list(gw_event):
+ """Test that Union[Model, List[Model]] correctly handles a list of items"""
+ # GIVEN an APIGatewayRestResolver with validation enabled
+ app = APIGatewayRestResolver(enable_validation=True)
+
+ class Item(BaseModel):
+ name: str
+ value: int
+
+ # WHEN a handler is defined with Union[Model, List[Model]] body parameter
+ @app.post("/items")
+ def handler(items: Annotated[Union[Item, List[Item]], Body()]) -> Dict[str, Any]:
+ # Should receive the full list, not just the first element
+ if isinstance(items, list):
+ return {"count": len(items), "items": [item.model_dump() for item in items]}
+ else:
+ return {"count": 1, "items": [items.model_dump()]}
+
+ gw_event["httpMethod"] = "POST"
+ gw_event["path"] = "/items"
+ # Send a list of items
+ gw_event["body"] = json.dumps(
+ [
+ {"name": "item1", "value": 10},
+ {"name": "item2", "value": 20},
+ {"name": "item3", "value": 30},
+ ],
+ )
+
+ # THEN the handler should receive all items in the list, not just the first one
+ result = app(gw_event, {})
+ assert result["statusCode"] == 200
+ body = json.loads(result["body"])
+ assert body["count"] == 3
+ assert len(body["items"]) == 3
+ assert body["items"][0]["name"] == "item1"
+ assert body["items"][1]["name"] == "item2"
+ assert body["items"][2]["name"] == "item3"
+
+
+def test_validate_union_single_or_list_body_with_single(gw_event):
+ """Test that Union[Model, List[Model]] correctly handles a single item"""
+ # GIVEN an APIGatewayRestResolver with validation enabled
+ app = APIGatewayRestResolver(enable_validation=True)
+
+ class Item(BaseModel):
+ name: str
+ value: int
+
+ # WHEN a handler is defined with Union[Model, List[Model]] body parameter
+ @app.post("/items")
+ def handler(items: Annotated[Union[Item, List[Item]], Body()]) -> Dict[str, Any]:
+ if isinstance(items, list):
+ return {"count": len(items), "items": [item.model_dump() for item in items]}
+ else:
+ return {"count": 1, "items": [items.model_dump()]}
+
+ gw_event["httpMethod"] = "POST"
+ gw_event["path"] = "/items"
+ # Send a single item
+ gw_event["body"] = json.dumps({"name": "single_item", "value": 42})
+
+ # THEN the handler should receive the single item
+ result = app(gw_event, {})
+ assert result["statusCode"] == 200
+ body = json.loads(result["body"])
+ assert body["count"] == 1
+ assert len(body["items"]) == 1
+ assert body["items"][0]["name"] == "single_item"
+ assert body["items"][0]["value"] == 42
+
+
+def test_validate_rootmodel_list_body(gw_event):
+ """Test that RootModel[List[Model]] correctly handles a list of items"""
+ # GIVEN an APIGatewayRestResolver with validation enabled
+ app = APIGatewayRestResolver(enable_validation=True)
+
+ class Item(BaseModel):
+ name: str
+ value: int
+
+ class ItemCollection(RootModel[List[Item]]):
+ root: List[Item]
+
+ # WHEN a handler is defined with RootModel[List[Model]] body parameter
+ @app.post("/items")
+ def handler(collection: Annotated[ItemCollection, Body()]) -> Dict[str, Any]:
+ # collection.root should contain the full list
+ items = collection.root
+ return {"count": len(items), "items": [item.model_dump() for item in items]}
+
+ gw_event["httpMethod"] = "POST"
+ gw_event["path"] = "/items"
+ # Send a list of items
+ gw_event["body"] = json.dumps(
+ [
+ {"name": "item1", "value": 100},
+ {"name": "item2", "value": 200},
+ ],
+ )
+
+ # THEN the handler should receive all items in the collection
+ result = app(gw_event, {})
+ assert result["statusCode"] == 200
+ body = json.loads(result["body"])
+ assert body["count"] == 2
+ assert len(body["items"]) == 2
+ assert body["items"][0]["name"] == "item1"
+ assert body["items"][0]["value"] == 100
+ assert body["items"][1]["name"] == "item2"
+ assert body["items"][1]["value"] == 200
+
+
+def test_validate_nested_union_with_sequence(gw_event):
+ """Test that nested Union types containing sequences are handled correctly"""
+ # GIVEN an APIGatewayRestResolver with validation enabled
+ app = APIGatewayRestResolver(enable_validation=True)
+
+ class Person(BaseModel):
+ name: str
+ age: int
+
+ # WHEN a handler is defined with a complex Union including List
+ @app.post("/people")
+ def handler(
+ data: Annotated[Union[str, List[Person], Person], Body()],
+ ) -> Dict[str, Any]:
+ if isinstance(data, str):
+ return {"type": "string", "value": data}
+ elif isinstance(data, list):
+ return {"type": "list", "count": len(data)}
+ else:
+ return {"type": "person", "name": data.name}
+
+ gw_event["httpMethod"] = "POST"
+ gw_event["path"] = "/people"
+ # Send a list
+ gw_event["body"] = json.dumps(
+ [
+ {"name": "Alice", "age": 30},
+ {"name": "Bob", "age": 25},
+ ],
+ )
+
+ # THEN the handler should receive the full list
+ result = app(gw_event, {})
+ assert result["statusCode"] == 200
+ body = json.loads(result["body"])
+ assert body["type"] == "list"
+ assert body["count"] == 2
+
+
+# ────────────────────────────────────────────────────────────────────
+# Regression tests for Union / RootModel / Optional sequence body
+# See: https://github.com/aws-powertools/powertools-lambda-python/issues/8057
+# ────────────────────────────────────────────────────────────────────
+
+
+class _Item(BaseModel):
+ name: str
+ value: int
+
+
+class _ItemCollection(RootModel[List[_Item]]):
+ pass
+
+
+_THREE_ITEMS = [
+ {"name": "a", "value": 1},
+ {"name": "b", "value": 2},
+ {"name": "c", "value": 3},
+]
+
+
+def _post_json(app, path, payload):
+ """Helper: build a minimal APIGW REST event, POST JSON, return parsed result."""
+ from tests.functional.utils import load_event
+
+ event = load_event("apiGatewayProxyEvent.json")
+ event["httpMethod"] = "POST"
+ event["path"] = path
+ event["body"] = json.dumps(payload)
+ result = app(event, {})
+ return result["statusCode"], json.loads(result["body"])
+
+
+# ---------- List[Model] | None ----------
+
+
+def test_optional_list_body_with_list():
+ """List[Model] | None must preserve the full list."""
+ app = APIGatewayRestResolver(enable_validation=True)
+
+ @app.post("/items")
+ def handler(items: Annotated[List[_Item] | None, Body()]) -> Dict[str, Any]:
+ assert isinstance(items, list)
+ return {"count": len(items)}
+
+ status, body = _post_json(app, "/items", _THREE_ITEMS)
+ assert status == 200
+ assert body["count"] == 3
+
+
+def test_optional_list_body_with_none():
+ """List[Model] | None must accept a null body gracefully."""
+ app = APIGatewayRestResolver(enable_validation=True)
+
+ @app.post("/items")
+ def handler(items: Annotated[List[_Item] | None, Body()] = None) -> Dict[str, Any]:
+ return {"received_none": items is None}
+
+ status, body = _post_json(app, "/items", None)
+ assert status == 200
+ assert body["received_none"] is True
+
+
+# ---------- Union[Model, List[Model]] | None ----------
+
+
+def test_optional_union_model_or_list_with_list():
+ """Union[Model, List[Model]] | None — send list, get full list."""
+ app = APIGatewayRestResolver(enable_validation=True)
+
+ @app.post("/items")
+ def handler(items: Annotated[Union[_Item, List[_Item]] | None, Body()]) -> Dict[str, Any]:
+ assert isinstance(items, list)
+ return {"count": len(items)}
+
+ status, body = _post_json(app, "/items", _THREE_ITEMS)
+ assert status == 200
+ assert body["count"] == 3
+
+
+def test_optional_union_model_or_list_with_single():
+ """Union[Model, List[Model]] | None — send single obj, get single obj."""
+ app = APIGatewayRestResolver(enable_validation=True)
+
+ @app.post("/items")
+ def handler(items: Annotated[Union[_Item, List[_Item]] | None, Body()]) -> Dict[str, Any]:
+ assert not isinstance(items, list)
+ return {"name": items.name}
+
+ status, body = _post_json(app, "/items", {"name": "solo", "value": 99})
+ assert status == 200
+ assert body["name"] == "solo"
+
+
+def test_optional_union_model_or_list_with_none():
+ """Union[Model, List[Model]] | None — send null, get None."""
+ app = APIGatewayRestResolver(enable_validation=True)
+
+ @app.post("/items")
+ def handler(items: Annotated[Union[_Item, List[_Item]] | None, Body()] = None) -> Dict[str, Any]:
+ return {"is_none": items is None}
+
+ status, body = _post_json(app, "/items", None)
+ assert status == 200
+ assert body["is_none"] is True
+
+
+# ---------- List[Model] directly (no Union / Optional) ----------
+
+
+def test_plain_list_body_preserves_all_items():
+ """List[Model] — baseline: must never truncate."""
+ app = APIGatewayRestResolver(enable_validation=True)
+
+ @app.post("/items")
+ def handler(items: Annotated[List[_Item], Body()]) -> Dict[str, Any]:
+ return {"count": len(items)}
+
+ status, body = _post_json(app, "/items", _THREE_ITEMS)
+ assert status == 200
+ assert body["count"] == 3
+
+
+# ---------- Empty list ----------
+
+
+def test_union_model_or_list_with_empty_list():
+ """Union[Model, List[Model]] with [] — must not crash on value[0]."""
+ app = APIGatewayRestResolver(enable_validation=True)
+
+ @app.post("/items")
+ def handler(items: Annotated[Union[_Item, List[_Item]], Body()]) -> Dict[str, Any]:
+ if isinstance(items, list):
+ return {"count": len(items)}
+ return {"count": 1}
+
+ status, body = _post_json(app, "/items", [])
+ assert status == 200
+ assert body["count"] == 0
+
+
+def test_plain_list_with_empty_list():
+ """List[Model] with [] — must accept empty list."""
+ app = APIGatewayRestResolver(enable_validation=True)
+
+ @app.post("/items")
+ def handler(items: Annotated[List[_Item], Body()]) -> Dict[str, Any]:
+ return {"count": len(items)}
+
+ status, body = _post_json(app, "/items", [])
+ assert status == 200
+ assert body["count"] == 0
+
+
+# ---------- Single-element list (boundary) ----------
+
+
+def test_union_model_or_list_with_single_element_list():
+ """Union[Model, List[Model]] with [single_item] — must NOT unwrap to scalar."""
+ app = APIGatewayRestResolver(enable_validation=True)
+
+ @app.post("/items")
+ def handler(items: Annotated[Union[_Item, List[_Item]], Body()]) -> Dict[str, Any]:
+ if isinstance(items, list):
+ return {"type": "list", "count": len(items)}
+ return {"type": "single"}
+
+ status, body = _post_json(app, "/items", [{"name": "only", "value": 1}])
+ assert status == 200
+ # Pydantic may match as single Item or list — either is valid,
+ # but it must NOT crash or lose data
+ assert body.get("count", 1) == 1
+
+
+# ---------- Union with primitive sequences ----------
+
+
+def test_union_str_or_list_dict():
+ """Union[str, List[dict]] — list of dicts must arrive intact."""
+ app = APIGatewayRestResolver(enable_validation=True)
+
+ @app.post("/data")
+ def handler(data: Annotated[Union[str, List[Dict[str, Any]]], Body()]) -> Dict[str, Any]:
+ if isinstance(data, list):
+ return {"type": "list", "count": len(data)}
+ return {"type": "str"}
+
+ payload = [{"key": "v1"}, {"key": "v2"}]
+ status, body = _post_json(app, "/data", payload)
+ assert status == 200
+ assert body["type"] == "list"
+ assert body["count"] == 2
+
+
+# ---------- RootModel edge cases ----------
+
+
+def test_optional_rootmodel_list_body():
+ """RootModel[List[Model]] | None — list must not be truncated."""
+ app = APIGatewayRestResolver(enable_validation=True)
+
+ @app.post("/items")
+ def handler(items: Annotated[_ItemCollection | None, Body()]) -> Dict[str, Any]:
+ return {"count": len(items.root)}
+
+ status, body = _post_json(app, "/items", _THREE_ITEMS)
+ assert status == 200
+ assert body["count"] == 3
+
+
+def test_union_rootmodel_and_model():
+ """Union[RootModel[List[Model]], Model] — list must not be truncated."""
+ app = APIGatewayRestResolver(enable_validation=True)
+
+ @app.post("/items")
+ def handler(items: Annotated[Union[_ItemCollection, _Item], Body()]) -> Dict[str, Any]:
+ if isinstance(items, _ItemCollection):
+ return {"type": "collection", "count": len(items.root)}
+ return {"type": "single", "name": items.name}
+
+ status, body = _post_json(app, "/items", _THREE_ITEMS)
+ assert status == 200
+ assert body["type"] == "collection"
+ assert body["count"] == 3
+
+
+# ---------- Python 3.10+ pipe Union syntax ----------
+
+
+def test_pipe_union_syntax_model_or_list():
+ """Model | List[Model] (PEP 604 syntax) — list must not be truncated."""
+ app = APIGatewayRestResolver(enable_validation=True)
+
+ @app.post("/items")
+ def handler(items: Annotated[_Item | List[_Item], Body()]) -> Dict[str, Any]: # noqa: FA102
+ if isinstance(items, list):
+ return {"count": len(items)}
+ return {"count": 1}
+
+ status, body = _post_json(app, "/items", _THREE_ITEMS)
+ assert status == 200
+ assert body["count"] == 3
+
+
+def test_pipe_union_optional_list():
+ """List[Model] | None (PEP 604 Optional) — list must not be truncated."""
+ app = APIGatewayRestResolver(enable_validation=True)
+
+ @app.post("/items")
+ def handler(items: Annotated[List[_Item] | None, Body()]) -> Dict[str, Any]: # noqa: FA102
+ if items is None:
+ return {"count": 0}
+ return {"count": len(items)}
+
+ status, body = _post_json(app, "/items", _THREE_ITEMS)
+ assert status == 200
+ assert body["count"] == 3
+
+
+# ---------- Deeply nested: RootModel[Union[Model, List[Model]]] ----------
+
+
+def test_rootmodel_wrapping_union_with_sequence():
+ """RootModel[Union[Model, List[Model]]] — inner Union sequence must be detected."""
+ app = APIGatewayRestResolver(enable_validation=True)
+
+ class FlexiblePayload(RootModel[Union[_Item, List[_Item]]]):
+ pass
+
+ @app.post("/items")
+ def handler(payload: Annotated[FlexiblePayload, Body()]) -> Dict[str, Any]:
+ data = payload.root
+ if isinstance(data, list):
+ return {"type": "list", "count": len(data)}
+ return {"type": "single", "name": data.name}
+
+ status, body = _post_json(app, "/items", _THREE_ITEMS)
+ assert status == 200
+ assert body["type"] == "list"
+ assert body["count"] == 3
+
+
+# ---------- Multiple resolvers (ALB, HTTP API, etc.) ----------
+
+
+def test_union_list_body_works_across_resolvers():
+ """Regression: ensure fix works for ALB and HTTP API resolvers too."""
+ for ResolverClass in [APIGatewayHttpResolver, ALBResolver]:
+ app = ResolverClass(enable_validation=True)
+
+ @app.post("/items")
+ def handler(items: Annotated[Union[_Item, List[_Item]], Body()]) -> Dict[str, Any]:
+ if isinstance(items, list):
+ return {"count": len(items)}
+ return {"count": 1}
+
+ # Build event appropriate for resolver
+ if ResolverClass is APIGatewayHttpResolver:
+ event = load_event("apiGatewayProxyV2Event.json")
+ event["requestContext"]["http"]["method"] = "POST"
+ event["requestContext"]["http"]["path"] = "/items"
+ event["rawPath"] = "/items"
+ else:
+ event = load_event("albEvent.json")
+ event["httpMethod"] = "POST"
+ event["path"] = "/items"
+
+ event["body"] = json.dumps(_THREE_ITEMS)
+ result = app(event, {})
+ assert result["statusCode"] == 200
+ body_result = json.loads(result["body"])
+ assert body_result["count"] == 3, f"Failed for {ResolverClass.__name__}"
+
+
+# ---------- Large list (stress boundary) ----------
+
+
+def test_union_list_body_large_payload():
+ """Union[Model, List[Model]] with 100 items — no truncation."""
+ app = APIGatewayRestResolver(enable_validation=True)
+
+ @app.post("/items")
+ def handler(items: Annotated[Union[_Item, List[_Item]], Body()]) -> Dict[str, Any]:
+ assert isinstance(items, list)
+ return {"count": len(items)}
+
+ big_payload = [{"name": f"item-{i}", "value": i} for i in range(100)]
+ status, body = _post_json(app, "/items", big_payload)
+ assert status == 200
+ assert body["count"] == 100
+
+
+# ---------- File upload (multipart/form-data) ----------
+
+
+def _build_multipart_body(fields: List[Dict], boundary: str = "----TestBoundary") -> Tuple[str, str]:
+ """
+ Build a multipart/form-data body and return (base64_body, content_type).
+
+ Each field dict can have:
+ - name: field name (required)
+ - value: str or bytes (required)
+ - filename: optional filename (makes it a file part)
+ - content_type: optional content type for the part
+ """
+ parts = []
+ for field in fields:
+ headers = f'Content-Disposition: form-data; name="{field["name"]}"'
+ if "filename" in field:
+ headers += f'; filename="{field["filename"]}"'
+ if "content_type" in field:
+ headers += f"\r\nContent-Type: {field['content_type']}"
+ value = field["value"]
+ if isinstance(value, str):
+ value = value.encode("utf-8")
+ parts.append((headers, value))
+
+ body = b""
+ for headers, value in parts:
+ body += f"--{boundary}\r\n".encode()
+ body += f"{headers}\r\n\r\n".encode()
+ body += value
+ body += b"\r\n"
+ body += f"--{boundary}--\r\n".encode()
+
+ content_type = f"multipart/form-data; boundary={boundary}"
+ return base64.b64encode(body).decode("utf-8"), content_type
+
+
+def test_file_upload_basic(gw_event):
+ """Test basic file upload with File() parameter."""
+ from aws_lambda_powertools.event_handler.openapi.params import File
+
+ app = APIGatewayRestResolver(enable_validation=True)
+
+ @app.post("/upload")
+ def upload(file_data: Annotated[bytes, File()]):
+ return {"size": len(file_data)}
+
+ body, content_type = _build_multipart_body(
+ [
+ {"name": "file_data", "value": b"hello world", "filename": "test.txt"},
+ ],
+ )
+
+ gw_event["httpMethod"] = "POST"
+ gw_event["path"] = "/upload"
+ gw_event["headers"]["content-type"] = content_type
+ gw_event["body"] = body
+ gw_event["isBase64Encoded"] = True
+
+ result = app(gw_event, {})
+ assert result["statusCode"] == 200
+ assert json.loads(result["body"]) == {"size": 11}
+
+
+def test_file_upload_with_form_field(gw_event):
+ """Test file upload mixed with a regular form field."""
+ from aws_lambda_powertools.event_handler.openapi.params import File
+
+ app = APIGatewayRestResolver(enable_validation=True)
+
+ @app.post("/upload")
+ def upload(
+ description: Annotated[str, Form()],
+ file_data: Annotated[bytes, File()],
+ ):
+ return {"description": description, "size": len(file_data)}
+
+ body, content_type = _build_multipart_body(
+ [
+ {"name": "description", "value": "my file"},
+ {"name": "file_data", "value": b"\x89PNG\r\n\x1a\n", "filename": "image.png", "content_type": "image/png"},
+ ],
+ )
+
+ gw_event["httpMethod"] = "POST"
+ gw_event["path"] = "/upload"
+ gw_event["headers"]["content-type"] = content_type
+ gw_event["body"] = body
+ gw_event["isBase64Encoded"] = True
+
+ result = app(gw_event, {})
+ assert result["statusCode"] == 200
+ parsed = json.loads(result["body"])
+ assert parsed["description"] == "my file"
+ assert parsed["size"] == 8
+
+
+def test_file_upload_missing_required(gw_event):
+ """Test that missing required File() parameter returns 422."""
+ from aws_lambda_powertools.event_handler.openapi.params import File
+
+ app = APIGatewayRestResolver(enable_validation=True)
+
+ @app.post("/upload")
+ def upload(file_data: Annotated[bytes, File()]):
+ return {"size": len(file_data)}
+
+ # Send empty multipart body (no file_data field)
+ body, content_type = _build_multipart_body(
+ [
+ {"name": "other_field", "value": "some value"},
+ ],
+ )
+
+ gw_event["httpMethod"] = "POST"
+ gw_event["path"] = "/upload"
+ gw_event["headers"]["content-type"] = content_type
+ gw_event["body"] = body
+ gw_event["isBase64Encoded"] = True
+
+ result = app(gw_event, {})
+ assert result["statusCode"] == 422
+ assert "missing" in result["body"]
+
+
+def test_file_upload_openapi_schema():
+ """Test that File() parameters generate correct OpenAPI schema."""
+ from aws_lambda_powertools.event_handler.openapi.params import File
+
+ app = APIGatewayRestResolver(enable_validation=True)
+
+ @app.post("/upload")
+ def upload(file_data: Annotated[bytes, File(description="The file to upload")]):
+ return {"size": len(file_data)}
+
+ schema = app.get_openapi_schema()
+ path = schema.paths["/upload"]
+ post_op = path.post
+
+ # Should have a request body with multipart/form-data
+ assert post_op.requestBody is not None
+ content = post_op.requestBody.content
+ assert "multipart/form-data" in content
+
+ # The schema should reference a binary format field
+ multipart_schema = content["multipart/form-data"].schema_
+ assert multipart_schema is not None
+
+
+def test_file_upload_non_base64(gw_event):
+ """Test file upload when body is not base64-encoded (edge case)."""
+ from aws_lambda_powertools.event_handler.openapi.params import File
+
+ app = APIGatewayRestResolver(enable_validation=True)
+
+ @app.post("/upload")
+ def upload(file_data: Annotated[bytes, File()]):
+ return {"size": len(file_data)}
+
+ # Build multipart body without base64 encoding
+ boundary = "----TestBoundary"
+ raw_body = (
+ f"--{boundary}\r\n"
+ f'Content-Disposition: form-data; name="file_data"; filename="test.txt"\r\n'
+ f"\r\n"
+ f"hello world\r\n"
+ f"--{boundary}--\r\n"
+ )
+
+ gw_event["httpMethod"] = "POST"
+ gw_event["path"] = "/upload"
+ gw_event["headers"]["content-type"] = f"multipart/form-data; boundary={boundary}"
+ gw_event["body"] = raw_body
+ gw_event["isBase64Encoded"] = False
+
+ result = app(gw_event, {})
+ assert result["statusCode"] == 200
+ assert json.loads(result["body"]) == {"size": 11}
+
+
+def test_file_upload_non_base64_emits_warning(gw_event):
+ """Test that non-base64 multipart body emits a warning about API Gateway config."""
+ from aws_lambda_powertools.event_handler.openapi.params import File
+
+ app = APIGatewayRestResolver(enable_validation=True)
+
+ @app.post("/upload")
+ def upload(file_data: Annotated[bytes, File()]):
+ return {"size": len(file_data)}
+
+ boundary = "----TestBoundary"
+ raw_body = (
+ f"--{boundary}\r\n"
+ f'Content-Disposition: form-data; name="file_data"; filename="test.txt"\r\n'
+ f"\r\n"
+ f"hello world\r\n"
+ f"--{boundary}--\r\n"
+ )
+
+ gw_event["httpMethod"] = "POST"
+ gw_event["path"] = "/upload"
+ gw_event["headers"]["content-type"] = f"multipart/form-data; boundary={boundary}"
+ gw_event["body"] = raw_body
+ gw_event["isBase64Encoded"] = False
+
+ with warnings.catch_warnings(record=True) as w:
+ warnings.simplefilter("always")
+ result = app(gw_event, {})
+
+ assert result["statusCode"] == 200
+ assert len(w) == 1
+ assert "Binary Media Types" in str(w[0].message)
+
+
+def test_file_upload_non_base64_binary_content(gw_event):
+ """Test file upload with raw binary bytes (e.g. JPEG) without base64 encoding."""
+ from aws_lambda_powertools.event_handler.openapi.params import File
+
+ app = APIGatewayRestResolver(enable_validation=True)
+
+ @app.post("/upload")
+ def upload(file_data: Annotated[bytes, File()]):
+ return {"size": len(file_data)}
+
+ # Simulate binary content with bytes that are NOT valid UTF-8 (like JPEG header 0xFF 0xD8)
+ binary_content = b"\xff\xd8\xff\xe0\x00\x10JFIF\x00"
+ boundary = "----TestBoundary"
+ raw_bytes = (
+ (
+ f"--{boundary}\r\n"
+ f'Content-Disposition: form-data; name="file_data"; filename="photo.jpg"\r\n'
+ f"Content-Type: image/jpeg\r\n"
+ f"\r\n"
+ ).encode("latin-1")
+ + binary_content
+ + f"\r\n--{boundary}--\r\n".encode("latin-1")
+ )
+
+ # Without binary mode, API Gateway passes body as latin-1 compatible string
+ gw_event["httpMethod"] = "POST"
+ gw_event["path"] = "/upload"
+ gw_event["headers"]["content-type"] = f"multipart/form-data; boundary={boundary}"
+ gw_event["body"] = raw_bytes.decode("latin-1")
+ gw_event["isBase64Encoded"] = False
+
+ with warnings.catch_warnings(record=True):
+ warnings.simplefilter("always")
+ result = app(gw_event, {})
+
+ assert result["statusCode"] == 200
+ assert json.loads(result["body"]) == {"size": len(binary_content)}
+
+
+def test_upload_file_with_metadata(gw_event):
+ """Test UploadFile annotation provides filename and content_type."""
+ from aws_lambda_powertools.event_handler.openapi.params import File, UploadFile
+
+ app = APIGatewayRestResolver(enable_validation=True)
+
+ @app.post("/upload")
+ def upload(file_data: Annotated[UploadFile, File()]):
+ return {
+ "filename": file_data.filename,
+ "content_type": file_data.content_type,
+ "size": len(file_data),
+ }
+
+ body, content_type = _build_multipart_body(
+ [
+ {"name": "file_data", "value": b"fake jpeg", "filename": "photo.jpg", "content_type": "image/jpeg"},
+ ],
+ )
+
+ gw_event["httpMethod"] = "POST"
+ gw_event["path"] = "/upload"
+ gw_event["headers"]["content-type"] = content_type
+ gw_event["body"] = body
+ gw_event["isBase64Encoded"] = True
+
+ result = app(gw_event, {})
+ assert result["statusCode"] == 200
+ parsed = json.loads(result["body"])
+ assert parsed["filename"] == "photo.jpg"
+ assert parsed["content_type"] == "image/jpeg"
+ assert parsed["size"] == 9
+
+
+def test_upload_file_mixed_with_form(gw_event):
+ """Test UploadFile + Form fields together."""
+ from aws_lambda_powertools.event_handler.openapi.params import File, UploadFile
+
+ app = APIGatewayRestResolver(enable_validation=True)
+
+ @app.post("/upload")
+ def upload(
+ file_data: Annotated[UploadFile, File()],
+ title: Annotated[str, Form()],
+ ):
+ return {
+ "title": title,
+ "filename": file_data.filename,
+ "size": len(file_data),
+ }
+
+ body, content_type = _build_multipart_body(
+ [
+ {"name": "title", "value": "My Document"},
+ {
+ "name": "file_data",
+ "value": b"pdf content here",
+ "filename": "doc.pdf",
+ "content_type": "application/pdf",
+ },
+ ],
+ )
+
+ gw_event["httpMethod"] = "POST"
+ gw_event["path"] = "/upload"
+ gw_event["headers"]["content-type"] = content_type
+ gw_event["body"] = body
+ gw_event["isBase64Encoded"] = True
+
+ result = app(gw_event, {})
+ assert result["statusCode"] == 200
+ parsed = json.loads(result["body"])
+ assert parsed["title"] == "My Document"
+ assert parsed["filename"] == "doc.pdf"
+ assert parsed["size"] == 16
+
+
+def test_upload_file_openapi_schema():
+ """Test UploadFile generates correct OpenAPI schema."""
+ from aws_lambda_powertools.event_handler.openapi.params import File, UploadFile
+
+ app = APIGatewayRestResolver(enable_validation=True)
+
+ @app.post("/upload")
+ def upload(file_data: Annotated[UploadFile, File(description="A file")]):
+ return {}
+
+ schema = app.get_openapi_schema()
+ schema_dict = schema.model_dump(exclude_none=True, by_alias=True)
+ upload_path = schema_dict["paths"]["/upload"]["post"]
+ content = upload_path["requestBody"]["content"]
+ assert "multipart/form-data" in content
+
+ # Resolve $ref to get the actual schema
+ ref = content["multipart/form-data"]["schema"]["$ref"]
+ schema_name = ref.split("/")[-1]
+ props = schema_dict["components"]["schemas"][schema_name]["properties"]
+ assert props["file_data"]["type"] == "string"
+ assert props["file_data"]["format"] == "binary"
+
+
+def test_multipart_missing_boundary(gw_event):
+ """Test that missing boundary in content-type raises ValueError."""
+ from aws_lambda_powertools.event_handler.openapi.params import File
+
+ app = APIGatewayRestResolver(enable_validation=True)
+
+ @app.post("/upload")
+ def upload(file_data: Annotated[bytes, File()]):
+ return {"size": len(file_data)}
+
+ gw_event["httpMethod"] = "POST"
+ gw_event["path"] = "/upload"
+ gw_event["headers"]["content-type"] = "multipart/form-data" # no boundary
+ gw_event["body"] = base64.b64encode(b"some data").decode()
+ gw_event["isBase64Encoded"] = True
+
+ with pytest.raises(ValueError, match="Missing boundary"):
+ app(gw_event, {})
+
+
+def test_multipart_quoted_boundary(gw_event):
+ """Test that boundary with quotes is parsed correctly."""
+ from aws_lambda_powertools.event_handler.openapi.params import File
+
+ app = APIGatewayRestResolver(enable_validation=True)
+
+ @app.post("/upload")
+ def upload(file_data: Annotated[bytes, File()]):
+ return {"size": len(file_data)}
+
+ boundary = "----TestBoundary"
+ body, _ = _build_multipart_body(
+ [
+ {"name": "file_data", "value": b"hello", "filename": "test.txt"},
+ ],
+ boundary=boundary,
+ )
+
+ gw_event["httpMethod"] = "POST"
+ gw_event["path"] = "/upload"
+ # Use quoted boundary
+ gw_event["headers"]["content-type"] = f'multipart/form-data; boundary="{boundary}"'
+ gw_event["body"] = body
+ gw_event["isBase64Encoded"] = True
+
+ result = app(gw_event, {})
+ assert result["statusCode"] == 200
+ assert json.loads(result["body"]) == {"size": 5}
+
+
+def test_multipart_multiple_values_same_field(gw_event):
+ """Test multiple values for the same field name are collected as list."""
+ from aws_lambda_powertools.event_handler.openapi.params import File, UploadFile
+
+ app = APIGatewayRestResolver(enable_validation=True)
+
+ @app.post("/upload")
+ def upload(file_data: Annotated[List[UploadFile], File()]):
+ return {"count": len(file_data), "filenames": [f.filename for f in file_data]}
+
+ # Build body with two parts having the same field name
+ boundary = "----TestBoundary"
+ raw = (
+ f"--{boundary}\r\n"
+ f'Content-Disposition: form-data; name="file_data"; filename="a.txt"\r\n'
+ f"\r\n"
+ f"content a\r\n"
+ f"--{boundary}\r\n"
+ f'Content-Disposition: form-data; name="file_data"; filename="b.txt"\r\n'
+ f"\r\n"
+ f"content b\r\n"
+ f"--{boundary}--\r\n"
+ ).encode()
+
+ gw_event["httpMethod"] = "POST"
+ gw_event["path"] = "/upload"
+ gw_event["headers"]["content-type"] = f"multipart/form-data; boundary={boundary}"
+ gw_event["body"] = base64.b64encode(raw).decode()
+ gw_event["isBase64Encoded"] = True
+
+ result = app(gw_event, {})
+ assert result["statusCode"] == 200
+ parsed = json.loads(result["body"])
+ assert parsed["count"] == 2
+ assert parsed["filenames"] == ["a.txt", "b.txt"]
+
+
+def test_multipart_three_values_same_field(gw_event):
+ """Test three or more values for same field name builds onto existing list."""
+ from aws_lambda_powertools.event_handler.openapi.params import File, UploadFile
+
+ app = APIGatewayRestResolver(enable_validation=True)
+
+ @app.post("/upload")
+ def upload(file_data: Annotated[List[UploadFile], File()]):
+ return {"count": len(file_data), "filenames": [f.filename for f in file_data]}
+
+ boundary = "----TestBoundary"
+ raw = (
+ f"--{boundary}\r\n"
+ f'Content-Disposition: form-data; name="file_data"; filename="a.txt"\r\n'
+ f"\r\n"
+ f"aaa\r\n"
+ f"--{boundary}\r\n"
+ f'Content-Disposition: form-data; name="file_data"; filename="b.txt"\r\n'
+ f"\r\n"
+ f"bbb\r\n"
+ f"--{boundary}\r\n"
+ f'Content-Disposition: form-data; name="file_data"; filename="c.txt"\r\n'
+ f"\r\n"
+ f"ccc\r\n"
+ f"--{boundary}--\r\n"
+ ).encode()
+
+ gw_event["httpMethod"] = "POST"
+ gw_event["path"] = "/upload"
+ gw_event["headers"]["content-type"] = f"multipart/form-data; boundary={boundary}"
+ gw_event["body"] = base64.b64encode(raw).decode()
+ gw_event["isBase64Encoded"] = True
+
+ result = app(gw_event, {})
+ assert result["statusCode"] == 200
+ parsed = json.loads(result["body"])
+ assert parsed["count"] == 3
+ assert parsed["filenames"] == ["a.txt", "b.txt", "c.txt"]
+
+
+def test_multipart_part_without_headers_separator(gw_event):
+ """Test that a malformed part missing the header/body separator is skipped."""
+ from aws_lambda_powertools.event_handler.openapi.params import File, UploadFile
+
+ app = APIGatewayRestResolver(enable_validation=True)
+
+ @app.post("/upload")
+ def upload(file_data: Annotated[UploadFile, File()]):
+ return {"filename": file_data.filename}
+
+ # Build a body with one malformed part (no \r\n\r\n) and one valid part
+ boundary = "----TestBoundary"
+ raw = (
+ f"--{boundary}\r\n"
+ f"This part has no header separator at all\r\n"
+ f"--{boundary}\r\n"
+ f'Content-Disposition: form-data; name="file_data"; filename="good.txt"\r\n'
+ f"\r\n"
+ f"good content\r\n"
+ f"--{boundary}--\r\n"
+ ).encode()
+
+ gw_event["httpMethod"] = "POST"
+ gw_event["path"] = "/upload"
+ gw_event["headers"]["content-type"] = f"multipart/form-data; boundary={boundary}"
+ gw_event["body"] = base64.b64encode(raw).decode()
+ gw_event["isBase64Encoded"] = True
+
+ result = app(gw_event, {})
+ assert result["statusCode"] == 200
+ parsed = json.loads(result["body"])
+ assert parsed["filename"] == "good.txt"
+
+
+def test_multipart_part_without_field_name(gw_event):
+ """Test that a part missing the name parameter in Content-Disposition is skipped."""
+ from aws_lambda_powertools.event_handler.openapi.params import File, UploadFile
+
+ app = APIGatewayRestResolver(enable_validation=True)
+
+ @app.post("/upload")
+ def upload(file_data: Annotated[UploadFile, File()]):
+ return {"filename": file_data.filename}
+
+ # Build a body with one part that has no name= param and one valid part
+ boundary = "----TestBoundary"
+ raw = (
+ f"--{boundary}\r\n"
+ f"Content-Disposition: form-data\r\n"
+ f"\r\n"
+ f"orphan content\r\n"
+ f"--{boundary}\r\n"
+ f'Content-Disposition: form-data; name="file_data"; filename="valid.txt"\r\n'
+ f"\r\n"
+ f"valid content\r\n"
+ f"--{boundary}--\r\n"
+ ).encode()
+
+ gw_event["httpMethod"] = "POST"
+ gw_event["path"] = "/upload"
+ gw_event["headers"]["content-type"] = f"multipart/form-data; boundary={boundary}"
+ gw_event["body"] = base64.b64encode(raw).decode()
+ gw_event["isBase64Encoded"] = True
+
+ result = app(gw_event, {})
+ assert result["statusCode"] == 200
+ parsed = json.loads(result["body"])
+ assert parsed["filename"] == "valid.txt"
+
+
+def test_upload_file_validate_error():
+ """Test UploadFile._validate raises ValueError for non-UploadFile values."""
+ from aws_lambda_powertools.event_handler.openapi.params import UploadFile
+
+ with pytest.raises(ValueError, match="Expected UploadFile, got str"):
+ UploadFile._validate("not an upload file")
+
+ with pytest.raises(ValueError, match="Expected UploadFile, got int"):
+ UploadFile._validate(42)
+
+
+def test_multipart_unclosed_quote_in_header():
+ """Test that _extract_header_param returns None when quote is unclosed."""
+ from aws_lambda_powertools.event_handler.middlewares.openapi_validation import _extract_header_param
+
+ # name=" is present but closing quote is missing
+ result = _extract_header_param('Content-Disposition: form-data; name="broken', "name")
+ assert result is None
+
+
+def test_multipart_generic_parse_error(gw_event):
+ """Test that non-ValueError exceptions during multipart parsing produce 422."""
+ from unittest.mock import patch
+
+ from aws_lambda_powertools.event_handler.openapi.params import File, UploadFile
+
+ app = APIGatewayRestResolver(enable_validation=True)
+
+ @app.post("/upload")
+ def upload(file_data: Annotated[UploadFile, File()]):
+ return {"filename": file_data.filename}
+
+ body_b64, content_type = _build_multipart_body(
+ [{"name": "file_data", "value": b"data", "filename": "test.txt"}],
+ )
+
+ gw_event["httpMethod"] = "POST"
+ gw_event["path"] = "/upload"
+ gw_event["headers"]["content-type"] = content_type
+ gw_event["body"] = body_b64
+ gw_event["isBase64Encoded"] = True
+
+ # Patch _parse_multipart_body to raise a non-ValueError (e.g. TypeError)
+ with patch(
+ "aws_lambda_powertools.event_handler.middlewares.openapi_validation._parse_multipart_body",
+ side_effect=TypeError("unexpected type"),
+ ):
+ result = app(gw_event, {})
+ assert result["statusCode"] == 422
+ body = json.loads(result["body"])
+ assert body["detail"][0]["type"] == "multipart_invalid"
+
+
+# ---------- Cookie parameter tests ----------
+
+
+def test_cookie_param_basic(gw_event):
+ """Test basic cookie parameter extraction from REST API v1 (Cookie header)."""
+ from aws_lambda_powertools.event_handler.openapi.params import Cookie
+
+ app = APIGatewayRestResolver(enable_validation=True)
+
+ @app.get("/me")
+ def handler(session_id: Annotated[str, Cookie()]):
+ return {"session_id": session_id}
+
+ gw_event["path"] = "/me"
+ gw_event["headers"]["cookie"] = "session_id=abc123; theme=dark"
+ # Clear multiValueHeaders to avoid interference
+ gw_event.pop("multiValueHeaders", None)
+
+ result = app(gw_event, {})
+ assert result["statusCode"] == 200
+ body = json.loads(result["body"])
+ assert body["session_id"] == "abc123"
+
+
+def test_cookie_param_missing_required(gw_event):
+ """Test that a missing required cookie returns 422."""
+ from aws_lambda_powertools.event_handler.openapi.params import Cookie
+
+ app = APIGatewayRestResolver(enable_validation=True)
+
+ @app.get("/me")
+ def handler(session_id: Annotated[str, Cookie()]):
+ return {"session_id": session_id}
+
+ gw_event["path"] = "/me"
+ gw_event["headers"]["cookie"] = "theme=dark"
+ gw_event.pop("multiValueHeaders", None)
+
+ result = app(gw_event, {})
+ assert result["statusCode"] == 422
+
+
+def test_cookie_param_with_default(gw_event):
+ """Test cookie parameter with a default value when cookie is absent."""
+ from aws_lambda_powertools.event_handler.openapi.params import Cookie
+
+ app = APIGatewayRestResolver(enable_validation=True)
+
+ @app.get("/me")
+ def handler(theme: Annotated[str, Cookie()] = "light"):
+ return {"theme": theme}
+
+ gw_event["path"] = "/me"
+ gw_event["headers"].pop("cookie", None)
+ gw_event.pop("multiValueHeaders", None)
+
+ result = app(gw_event, {})
+ assert result["statusCode"] == 200
+ body = json.loads(result["body"])
+ assert body["theme"] == "light"
+
+
+def test_cookie_param_multiple_cookies(gw_event):
+ """Test extracting multiple cookie parameters."""
+ from aws_lambda_powertools.event_handler.openapi.params import Cookie
+
+ app = APIGatewayRestResolver(enable_validation=True)
+
+ @app.get("/me")
+ def handler(
+ session_id: Annotated[str, Cookie()],
+ theme: Annotated[str, Cookie()] = "light",
+ ):
+ return {"session_id": session_id, "theme": theme}
+
+ gw_event["path"] = "/me"
+ gw_event["headers"]["cookie"] = "session_id=abc123; theme=dark"
+ gw_event.pop("multiValueHeaders", None)
+
+ result = app(gw_event, {})
+ assert result["statusCode"] == 200
+ body = json.loads(result["body"])
+ assert body["session_id"] == "abc123"
+ assert body["theme"] == "dark"
+
+
+def test_cookie_param_int_validation(gw_event):
+ """Test cookie parameter with int type validation."""
+ from aws_lambda_powertools.event_handler.openapi.params import Cookie
+
+ app = APIGatewayRestResolver(enable_validation=True)
+
+ @app.get("/me")
+ def handler(visits: Annotated[int, Cookie()]):
+ return {"visits": visits}
+
+ gw_event["path"] = "/me"
+ gw_event["headers"]["cookie"] = "visits=42"
+ gw_event.pop("multiValueHeaders", None)
+
+ result = app(gw_event, {})
+ assert result["statusCode"] == 200
+ body = json.loads(result["body"])
+ assert body["visits"] == 42
+
+ # Invalid int
+ gw_event["headers"]["cookie"] = "visits=not_a_number"
+ result = app(gw_event, {})
+ assert result["statusCode"] == 422
+
+
+def test_cookie_param_http_api_v2(gw_event_http):
+ """Test cookie parameter with HTTP API v2 (dedicated cookies field)."""
+ from aws_lambda_powertools.event_handler.openapi.params import Cookie
+
+ app = APIGatewayHttpResolver(enable_validation=True)
+
+ @app.get("/me")
+ def handler(session_id: Annotated[str, Cookie()]):
+ return {"session_id": session_id}
+
+ gw_event_http["rawPath"] = "/me"
+ gw_event_http["requestContext"]["http"]["method"] = "GET"
+ gw_event_http["cookies"] = ["session_id=xyz789", "theme=dark"]
+
+ result = app(gw_event_http, {})
+ assert result["statusCode"] == 200
+ body = json.loads(result["body"])
+ assert body["session_id"] == "xyz789"
+
+
+def test_cookie_param_lambda_function_url(gw_event_lambda_url):
+ """Test cookie parameter with Lambda Function URL (v2 format)."""
+ from aws_lambda_powertools.event_handler.openapi.params import Cookie
+
+ app = LambdaFunctionUrlResolver(enable_validation=True)
+
+ @app.get("/me")
+ def handler(session_id: Annotated[str, Cookie()]):
+ return {"session_id": session_id}
+
+ gw_event_lambda_url["rawPath"] = "/me"
+ gw_event_lambda_url["requestContext"]["http"]["method"] = "GET"
+ gw_event_lambda_url["cookies"] = ["session_id=fn_url_abc"]
+
+ result = app(gw_event_lambda_url, {})
+ assert result["statusCode"] == 200
+ body = json.loads(result["body"])
+ assert body["session_id"] == "fn_url_abc"
+
+
+def test_cookie_param_alb(gw_event_alb):
+ """Test cookie parameter with ALB (Cookie header in multiValueHeaders)."""
+ from aws_lambda_powertools.event_handler.openapi.params import Cookie
+
+ app = ALBResolver(enable_validation=True)
+
+ @app.get("/me")
+ def handler(session_id: Annotated[str, Cookie()]):
+ return {"session_id": session_id}
+
+ gw_event_alb["path"] = "/me"
+ gw_event_alb["httpMethod"] = "GET"
+ gw_event_alb["multiValueHeaders"]["cookie"] = ["session_id=alb_abc"]
+
+ result = app(gw_event_alb, {})
+ assert result["statusCode"] == 200
+ body = json.loads(result["body"])
+ assert body["session_id"] == "alb_abc"
+
+
+def test_cookie_param_openapi_schema():
+ """Test that Cookie() generates correct OpenAPI schema with in=cookie."""
+ from aws_lambda_powertools.event_handler.openapi.params import Cookie
+
+ app = APIGatewayRestResolver(enable_validation=True)
+
+ @app.get("/me")
+ def handler(
+ session_id: Annotated[str, Cookie(description="Session identifier")],
+ theme: Annotated[str, Cookie(description="UI theme")] = "light",
+ ):
+ return {"session_id": session_id}
+
+ schema = app.get_openapi_schema()
+ schema_dict = schema.model_dump(mode="json", by_alias=True, exclude_none=True)
+
+ path = schema_dict["paths"]["/me"]["get"]
+ params = path["parameters"]
+
+ cookie_params = [p for p in params if p["in"] == "cookie"]
+ assert len(cookie_params) == 2
+
+ session_param = next(p for p in cookie_params if p["name"] == "session_id")
+ assert session_param["required"] is True
+ assert session_param["description"] == "Session identifier"
+
+ theme_param = next(p for p in cookie_params if p["name"] == "theme")
+ assert theme_param.get("required") is not True
+ assert theme_param["description"] == "UI theme"
+
+
+def test_cookie_param_with_query_and_header(gw_event):
+ """Test that Cookie(), Query(), and Header() work together."""
+ from aws_lambda_powertools.event_handler.openapi.params import Cookie
+
+ app = APIGatewayRestResolver(enable_validation=True)
+
+ @app.get("/me")
+ def handler(
+ user_id: Annotated[str, Query()],
+ x_request_id: Annotated[str, Header()],
+ session_id: Annotated[str, Cookie()],
+ ):
+ return {
+ "user_id": user_id,
+ "x_request_id": x_request_id,
+ "session_id": session_id,
+ }
+
+ gw_event["path"] = "/me"
+ gw_event["queryStringParameters"] = {"user_id": "u123"}
+ gw_event["multiValueQueryStringParameters"] = {"user_id": ["u123"]}
+ gw_event["headers"]["x-request-id"] = "req-456"
+ gw_event["multiValueHeaders"] = {"x-request-id": ["req-456"], "cookie": ["session_id=sess-789"]}
+ gw_event["headers"]["cookie"] = "session_id=sess-789"
+
+ result = app(gw_event, {})
+ assert result["statusCode"] == 200
+ body = json.loads(result["body"])
+ assert body["user_id"] == "u123"
+ assert body["x_request_id"] == "req-456"
+ assert body["session_id"] == "sess-789"
+
+
+def test_cookie_param_no_cookies_in_request(gw_event):
+ """Test that empty cookies dict is handled gracefully."""
+ from aws_lambda_powertools.event_handler.openapi.params import Cookie
+
+ app = APIGatewayRestResolver(enable_validation=True)
+
+ @app.get("/me")
+ def handler(theme: Annotated[str, Cookie()] = "light"):
+ return {"theme": theme}
+
+ gw_event["path"] = "/me"
+ gw_event["headers"] = {}
+ gw_event.pop("multiValueHeaders", None)
+
+ result = app(gw_event, {})
+ assert result["statusCode"] == 200
+ body = json.loads(result["body"])
+ assert body["theme"] == "light"
+
+
+def test_cookie_param_vpc_lattice_v2(gw_event_vpc_lattice):
+ """Test cookie parameter with VPC Lattice v2 (headers are lists)."""
+ from aws_lambda_powertools.event_handler.openapi.params import Cookie
+
+ app = VPCLatticeV2Resolver(enable_validation=True)
+
+ @app.get("/me")
+ def handler(session_id: Annotated[str, Cookie()]):
+ return {"session_id": session_id}
+
+ gw_event_vpc_lattice["method"] = "GET"
+ gw_event_vpc_lattice["path"] = "/me"
+ gw_event_vpc_lattice["headers"]["cookie"] = ["session_id=lattice_abc"]
+
+ result = app(gw_event_vpc_lattice, {})
+ assert result["statusCode"] == 200
+ body = json.loads(result["body"])
+ assert body["session_id"] == "lattice_abc"
+
+
+def test_cookie_param_vpc_lattice_v1(gw_event_vpc_lattice_v1):
+ """Test cookie parameter with VPC Lattice v1 (comma-separated headers)."""
+ from aws_lambda_powertools.event_handler.openapi.params import Cookie
+
+ app = VPCLatticeResolver(enable_validation=True)
+
+ @app.get("/me")
+ def handler(session_id: Annotated[str, Cookie()]):
+ return {"session_id": session_id}
+
+ gw_event_vpc_lattice_v1["method"] = "GET"
+ gw_event_vpc_lattice_v1["raw_path"] = "/me"
+ gw_event_vpc_lattice_v1["headers"]["cookie"] = "session_id=lattice_v1_abc"
+
+ result = app(gw_event_vpc_lattice_v1, {})
+ assert result["statusCode"] == 200
+ body = json.loads(result["body"])
+ assert body["session_id"] == "lattice_v1_abc"
+
+
+def test_alb_response_none_body_with_validation(gw_event_alb):
+ # GIVEN an ALBResolver with validation enabled
+ app = ALBResolver(enable_validation=True)
+
+ gw_event_alb["path"] = "/no-content"
+ gw_event_alb["httpMethod"] = "DELETE"
+
+ # WHEN a handler returns Response with body=None and return type is None
+ @app.delete("/no-content")
+ def handler() -> None:
+ return Response(status_code=204, body=None)
+
+ # THEN the response should be 204 with empty body (not 422 validation error)
+ result = app(gw_event_alb, {})
+ assert result["statusCode"] == 204
+ assert result["body"] == ""
+
+
+def test_alb_response_typed_none_body_with_validation(gw_event_alb):
+ # GIVEN an ALBResolver with validation enabled
+ app = ALBResolver(enable_validation=True)
+
+ gw_event_alb["path"] = "/no-content"
+ gw_event_alb["httpMethod"] = "DELETE"
+
+ # WHEN a handler returns Response[None] with body=None
+ @app.delete("/no-content")
+ def handler() -> Response[None]:
+ return Response(status_code=204, body=None)
+
+ # THEN the response should be 204 with empty body (not 422 validation error)
+ result = app(gw_event_alb, {})
+ assert result["statusCode"] == 204
+ assert result["body"] == ""
diff --git a/tests/functional/event_handler/_pydantic/test_openapi_with_pep563.py b/tests/functional/event_handler/_pydantic/test_openapi_with_pep563.py
new file mode 100644
index 00000000000..35ce00b8482
--- /dev/null
+++ b/tests/functional/event_handler/_pydantic/test_openapi_with_pep563.py
@@ -0,0 +1,118 @@
+from __future__ import annotations
+
+from pydantic import BaseModel, Field
+from typing_extensions import Annotated # noqa: TC002
+
+from aws_lambda_powertools.event_handler.api_gateway import APIGatewayRestResolver
+from aws_lambda_powertools.event_handler.openapi.models import (
+ ParameterInType,
+ Schema,
+)
+from aws_lambda_powertools.event_handler.openapi.params import (
+ Body,
+ Query,
+)
+
+JSON_CONTENT_TYPE = "application/json"
+
+
+class Todo(BaseModel):
+ id: int = Field(examples=[1])
+ title: str = Field(examples=["Example 1"])
+ priority: float = Field(examples=[0.5])
+ completed: bool = Field(examples=[True])
+
+
+def test_openapi_with_pep563_and_input_model():
+ app = APIGatewayRestResolver()
+
+ @app.get("/users", summary="Get Users", operation_id="GetUsers", description="Get paginated users", tags=["Users"])
+ def handler(
+ count: Annotated[
+ int,
+ Query(gt=0, lt=100, examples=["Example 1"]),
+ ] = 1,
+ ):
+ print(count)
+ raise NotImplementedError()
+
+ schema = app.get_openapi_schema()
+
+ get = schema.paths["/users"].get
+ assert len(get.parameters) == 1
+ assert get.summary == "Get Users"
+ assert get.operationId == "GetUsers"
+ assert get.description == "Get paginated users"
+ assert get.tags == ["Users"]
+
+ parameter = get.parameters[0]
+ assert parameter.required is False
+ assert parameter.name == "count"
+ assert parameter.in_ == ParameterInType.query
+ assert parameter.schema_.type == "integer"
+ assert parameter.schema_.default == 1
+ assert parameter.schema_.title == "Count"
+ assert parameter.schema_.exclusiveMinimum == 0
+ assert parameter.schema_.exclusiveMaximum == 100
+ assert len(parameter.schema_.examples) == 1
+ assert parameter.schema_.examples[0] == "Example 1"
+
+
+def test_openapi_with_pep563_and_output_model():
+ app = APIGatewayRestResolver()
+
+ @app.get("/")
+ def handler() -> Todo:
+ return Todo(id=0, title="", priority=0.0, completed=False)
+
+ schema = app.get_openapi_schema()
+ assert "Todo" in schema.components.schemas
+ todo_schema = schema.components.schemas["Todo"]
+ assert isinstance(todo_schema, Schema)
+
+ assert "id" in todo_schema.properties
+ id_property = todo_schema.properties["id"]
+ assert id_property.examples == [1]
+
+ assert "title" in todo_schema.properties
+ title_property = todo_schema.properties["title"]
+ assert title_property.examples == ["Example 1"]
+
+ assert "priority" in todo_schema.properties
+ priority_property = todo_schema.properties["priority"]
+ assert priority_property.examples == [0.5]
+
+ assert "completed" in todo_schema.properties
+ completed_property = todo_schema.properties["completed"]
+ assert completed_property.examples == [True]
+
+
+def test_openapi_with_pep563_and_annotated_body():
+ app = APIGatewayRestResolver()
+
+ @app.post("/todo")
+ def create_todo(
+ todo_create_request: Annotated[Todo, Body(title="New Todo")],
+ ) -> dict:
+ return {"message": f"Created todo {todo_create_request.title}"}
+
+ schema = app.get_openapi_schema()
+ assert "Todo" in schema.components.schemas
+ todo_schema = schema.components.schemas["Todo"]
+ assert isinstance(todo_schema, Schema)
+
+ assert "id" in todo_schema.properties
+ id_property = todo_schema.properties["id"]
+ assert id_property.examples == [1]
+
+ assert "title" in todo_schema.properties
+ title_property = todo_schema.properties["title"]
+ assert title_property.examples == ["Example 1"]
+
+ assert "priority" in todo_schema.properties
+ priority_property = todo_schema.properties["priority"]
+ assert priority_property.examples == [0.5]
+
+ assert "completed" in todo_schema.properties
+ completed_property = todo_schema.properties["completed"]
+ assert completed_property.examples == [True]
diff --git a/tests/functional/event_handler/_pydantic/test_per_route_validation.py b/tests/functional/event_handler/_pydantic/test_per_route_validation.py
new file mode 100644
index 00000000000..f6742b960ee
--- /dev/null
+++ b/tests/functional/event_handler/_pydantic/test_per_route_validation.py
@@ -0,0 +1,301 @@
+from typing import cast
+
+from pydantic import BaseModel
+
+from aws_lambda_powertools.event_handler import APIGatewayRestResolver
+from tests.functional.utils import load_event
+
+
+class TodoItem(BaseModel):
+ name: str
+ completed: bool = False
+
+
+def test_per_route_validation_enabled_on_single_route():
+ # GIVEN APIGatewayRestResolver with global enable_validation
+ # AND one route with explicit enable_validation=True
+ # AND one route without explicit validation (inherits global)
+ app = APIGatewayRestResolver(enable_validation=True)
+
+ @app.get("/explicitly-validated", enable_validation=True)
+ def explicitly_validated_route() -> TodoItem:
+ return TodoItem(name="test", completed=True)
+
+ @app.get("/inherit-validated")
+ def inherit_validated_route() -> TodoItem:
+ return TodoItem(name="inherit", completed=False)
+
+ # WHEN calling the explicitly validated route
+ event = load_event("apiGatewayProxyEvent.json")
+ event["path"] = "/explicitly-validated"
+ event["httpMethod"] = "GET"
+
+ result = app(event, {})
+
+ # THEN response should be validated and successful
+ assert result["statusCode"] == 200
+ assert '"name":"test"' in result["body"]
+
+ # WHEN calling the route that inherits validation
+ event["path"] = "/inherit-validated"
+ result = app(event, {})
+
+ # THEN response should also be validated
+ assert result["statusCode"] == 200
+ assert "inherit" in result["body"]
+
+
+def test_per_route_validation_disabled_on_single_route():
+ # GIVEN APIGatewayRestResolver with global enable_validation=True
+ # AND one route with enable_validation=False
+ app = APIGatewayRestResolver(enable_validation=True)
+
+ @app.get("/validated")
+ def validated_route() -> TodoItem:
+ return TodoItem(name="test", completed=True)
+
+ @app.get("/not-validated", enable_validation=False)
+ def not_validated_route() -> dict:
+ # This returns invalid data that doesn't match TodoItem but should not fail
+ return {"invalid": "data", "extra": "field"}
+
+ # WHEN calling the validated route
+ event = load_event("apiGatewayProxyEvent.json")
+ event["path"] = "/validated"
+ event["httpMethod"] = "GET"
+
+ result = app(event, {})
+
+ # THEN response should be validated and successful
+ assert result["statusCode"] == 200
+ assert '"name":"test"' in result["body"]
+
+ # WHEN calling the non-validated route with invalid response
+ event["path"] = "/not-validated"
+ result = app(event, {})
+
+ # THEN response should bypass validation
+ assert result["statusCode"] == 200
+ assert "invalid" in result["body"]
+
+
+def test_per_route_validation_request_body_validation():
+ # GIVEN APIGatewayRestResolver WITH global validation enabled
+ # AND routes with different validation settings
+ app = APIGatewayRestResolver(enable_validation=True)
+
+ @app.post("/create")
+ def create_item(item: TodoItem) -> TodoItem:
+ return item
+
+ @app.post("/create-no-validation", enable_validation=False)
+ def create_item_no_validation() -> dict:
+ # Without validation, we manually parse the body
+ body = app.current_event.json_body
+ return body
+
+ # WHEN calling validated route with valid body
+ event = load_event("apiGatewayProxyEvent.json")
+ event["path"] = "/create"
+ event["httpMethod"] = "POST"
+ event["body"] = '{"name": "New Task", "completed": false}'
+
+ result = app(event, {})
+
+ # THEN request should be validated and successful
+ assert result["statusCode"] == 200
+ assert "New Task" in result["body"]
+
+ # WHEN calling validated route with invalid body
+ event["body"] = '{"invalid": "data"}'
+ result = app(event, {})
+
+ # THEN validation should fail with 422
+ assert result["statusCode"] == 422
+
+ # WHEN calling non-validated route with any body
+ event["path"] = "/create-no-validation"
+ event["body"] = '{"invalid": "data"}'
+ result = app(event, {})
+
+ # THEN should succeed without validation
+ assert result["statusCode"] == 200
+
+
+def test_per_route_validation_inherits_from_resolver():
+ # GIVEN APIGatewayRestResolver with global enable_validation=True
+ # AND routes without explicit enable_validation setting
+ app = APIGatewayRestResolver(enable_validation=True)
+
+ @app.get("/route1")
+ def route1() -> TodoItem:
+ return TodoItem(name="test", completed=True)
+
+ @app.post("/route2")
+ def route2(item: TodoItem) -> TodoItem:
+ return item
+
+ # WHEN calling routes without explicit validation setting
+ event = load_event("apiGatewayProxyEvent.json")
+ event["path"] = "/route1"
+ event["httpMethod"] = "GET"
+
+ result = app(event, {})
+
+ # THEN they should inherit global validation setting
+ assert result["statusCode"] == 200
+
+ # WHEN calling POST with invalid body
+ event["path"] = "/route2"
+ event["httpMethod"] = "POST"
+ event["body"] = '{"invalid": "data"}'
+
+ result = app(event, {})
+
+ # THEN validation should be applied (422 error)
+ assert result["statusCode"] == 422
+
+
+def test_per_route_validation_mixed_routes():
+ # GIVEN APIGatewayRestResolver with mixed validation settings
+ app = APIGatewayRestResolver(enable_validation=True)
+
+ @app.get("/always-validated")
+ def always_validated() -> TodoItem:
+ return TodoItem(name="validated", completed=True)
+
+ @app.get("/never-validated", enable_validation=False)
+ def never_validated():
+ # Return invalid TodoItem structure
+ return {"wrong": "structure"}
+
+ @app.get("/inherit-global")
+ def inherit_global() -> TodoItem:
+ return TodoItem(name="inherit", completed=False)
+
+ event = load_event("apiGatewayProxyEvent.json")
+ event["httpMethod"] = "GET"
+
+ # WHEN calling route with global validation (enable_validation not set)
+ event["path"] = "/inherit-global"
+ result = app(event, {})
+ assert result["statusCode"] == 200
+ assert "inherit" in result["body"]
+
+ # WHEN calling route with explicit validation=False returning invalid data
+ event["path"] = "/never-validated"
+ result = app(event, {})
+ # THEN should succeed without validation
+ assert result["statusCode"] == 200
+ assert "wrong" in result["body"]
+
+ # WHEN calling route with inherited validation
+ event["path"] = "/always-validated"
+ result = app(event, {})
+ assert result["statusCode"] == 200
+ assert "validated" in result["body"]
+
+
+def test_per_route_validation_with_resolver_disabled():
+ # GIVEN APIGatewayRestResolver with global validation disabled (default)
+ # Note: Per-route enable_validation=True requires the resolver to have
+ # enable_validation=True for the middleware to exist. This test documents
+ # that you can't opt-in to validation per-route without global validation.
+ app = APIGatewayRestResolver() # enable_validation=False by default
+
+ @app.get("/no-explicit-setting")
+ def default_route() -> TodoItem:
+ return TodoItem(name="test", completed=True)
+
+ event = load_event("apiGatewayProxyEvent.json")
+ event["httpMethod"] = "GET"
+
+ # WHEN calling route without explicit setting (inherits False)
+ event["path"] = "/no-explicit-setting"
+ result = app(event, {})
+
+ # THEN should not be validated (returns as-is)
+ assert result["statusCode"] == 200
+ assert "test" in result["body"]
+
+
+def test_per_route_validation_response_error_code():
+ # GIVEN APIGatewayRestResolver with custom response_validation_error_http_code
+ app = APIGatewayRestResolver(enable_validation=True)
+
+ @app.get("/invalid-response")
+ def invalid_response() -> TodoItem:
+ # Return dict that doesn't match TodoItem model to test validation error handling
+ return cast(TodoItem, {"bad": "response"})
+
+ # WHEN calling route that returns invalid response
+ event = load_event("apiGatewayProxyEvent.json")
+ event["path"] = "/invalid-response"
+ event["httpMethod"] = "GET"
+
+ result = app(event, {})
+
+ # THEN should return 422 Unprocessable Entity (default response validation error code)
+ assert result["statusCode"] == 422
+
+
+def test_per_route_validation_with_pydantic_v2():
+ """Test that per-route validation actually validates when resolver has validation disabled"""
+ # GIVEN APIGatewayRestResolver WITHOUT global validation
+ app = APIGatewayRestResolver()
+
+ class Task(BaseModel):
+ title: str
+ priority: int
+
+ @app.get("/task", enable_validation=True)
+ def get_task() -> Task:
+ # Return invalid data — missing 'title' and 'priority'
+ return cast(Task, {"wrong": "data"})
+
+ @app.get("/unvalidated-task")
+ def get_unvalidated_task():
+ return {"title": "Anything", "extra": "field"}
+
+ event = load_event("apiGatewayProxyEvent.json")
+ event["httpMethod"] = "GET"
+
+ # WHEN calling validated route with invalid data
+ event["path"] = "/task"
+ result = app(event, {})
+
+ # THEN validation must reject it with 422
+ assert result["statusCode"] == 422
+
+ # WHEN calling unvalidated route
+ event["path"] = "/unvalidated-task"
+ result = app(event, {})
+
+ # THEN should return as-is without validation
+ assert result["statusCode"] == 200
+ assert "extra" in result["body"]
+
+
+def test_per_route_opt_in_validation_with_valid_data():
+ """Test that per-route opt-in validation passes valid data and serializes correctly"""
+ # GIVEN APIGatewayRestResolver WITHOUT global validation
+ app = APIGatewayRestResolver()
+
+ class Task(BaseModel):
+ title: str
+ priority: int
+
+ @app.get("/task", enable_validation=True)
+ def get_task() -> Task:
+ return Task(title="Important", priority=1)
+
+ event = load_event("apiGatewayProxyEvent.json")
+ event["httpMethod"] = "GET"
+ event["path"] = "/task"
+
+ # WHEN calling validated route with valid data
+ result = app(event, {})
+
+ # THEN validation passes and response is serialized
+ assert result["statusCode"] == 200
+ assert "Important" in result["body"]
diff --git a/tests/functional/event_handler/_pydantic/test_resolve_async_validation.py b/tests/functional/event_handler/_pydantic/test_resolve_async_validation.py
new file mode 100644
index 00000000000..92b414f72b5
--- /dev/null
+++ b/tests/functional/event_handler/_pydantic/test_resolve_async_validation.py
@@ -0,0 +1,55 @@
+import asyncio
+
+from aws_lambda_powertools.event_handler.api_gateway import (
+ APIGatewayHttpResolver,
+ BaseRouter,
+)
+from tests.functional.utils import load_event
+
+API_RESTV2_EVENT = load_event("apiGatewayProxyV2Event_GET.json")
+
+
+def _setup_app(app, event):
+ BaseRouter.current_event = app._to_proxy_event(event)
+ BaseRouter.lambda_context = {}
+
+
+class TestResolveAsyncValidation:
+ def test_validation_middleware_created_and_used(self):
+ # GIVEN a resolver with validation enabled and an async handler
+ app = APIGatewayHttpResolver(enable_validation=True)
+
+ @app.get("/my/path")
+ async def get_lambda() -> dict:
+ await asyncio.sleep(0)
+ return {"message": "validated"}
+
+ # WHEN calling _resolve_async
+ _setup_app(app, API_RESTV2_EVENT)
+ result = asyncio.run(app._resolve_async())
+
+ # THEN the validation middlewares are created and the response is valid
+ response = result.build(app.current_event, app._cors)
+ assert response["statusCode"] == 200
+ assert hasattr(app, "_request_validation_middleware")
+ assert hasattr(app, "_response_validation_middleware")
+
+ def test_validation_middleware_lazy_created_for_per_route_validation(self):
+ # GIVEN a resolver WITHOUT global validation, but a route WITH enable_validation=True
+ app = APIGatewayHttpResolver()
+ assert not hasattr(app, "_request_validation_middleware")
+
+ @app.get("/my/path", enable_validation=True)
+ async def get_lambda() -> dict:
+ await asyncio.sleep(0)
+ return {"message": "lazy validated"}
+
+ # WHEN calling _resolve_async (triggers lazy creation in Route.call_async)
+ _setup_app(app, API_RESTV2_EVENT)
+ result = asyncio.run(app._resolve_async())
+
+ # THEN validation middlewares are lazily created on the app
+ response = result.build(app.current_event, app._cors)
+ assert response["statusCode"] == 200
+ assert hasattr(app, "_request_validation_middleware")
+ assert hasattr(app, "_response_validation_middleware")
diff --git a/tests/functional/event_handler/required_dependencies/__init__.py b/tests/functional/event_handler/required_dependencies/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/tests/functional/event_handler/required_dependencies/appsync/__init__.py b/tests/functional/event_handler/required_dependencies/appsync/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/tests/functional/event_handler/required_dependencies/appsync/test_appsync_batch_resolvers.py b/tests/functional/event_handler/required_dependencies/appsync/test_appsync_batch_resolvers.py
new file mode 100644
index 00000000000..2466ac6d6a3
--- /dev/null
+++ b/tests/functional/event_handler/required_dependencies/appsync/test_appsync_batch_resolvers.py
@@ -0,0 +1,1107 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+import pytest
+
+from aws_lambda_powertools.event_handler import AppSyncResolver
+from aws_lambda_powertools.event_handler.graphql_appsync.exceptions import InvalidBatchResponse, ResolverNotFoundError
+from aws_lambda_powertools.event_handler.graphql_appsync.router import Router
+from aws_lambda_powertools.utilities.typing import LambdaContext
+from aws_lambda_powertools.warnings import PowertoolsUserWarning
+from tests.functional.utils import load_event
+
+if TYPE_CHECKING:
+ from aws_lambda_powertools.utilities.data_classes import AppSyncResolverEvent
+
+
+# TESTS RECEIVING THE EVENT PARTIALLY AND PROCESS EACH RECORD PER TIME.
+def test_resolve_batch_processing_with_related_events_one_at_time():
+ # GIVEN An event with multiple requests to fetch related posts for different post IDs.
+ event = [
+ {
+ "arguments": {},
+ "identity": "None",
+ "source": {
+ "post_id": "3",
+ "title": "Third book",
+ },
+ "info": {
+ "selectionSetList": [
+ "title",
+ ],
+ "selectionSetGraphQL": "{\n title\n}",
+ "fieldName": "relatedPosts",
+ "parentTypeName": "Post",
+ },
+ },
+ {
+ "arguments": {},
+ "identity": "None",
+ "source": {
+ "post_id": "4",
+ "title": "Fifth book",
+ },
+ "info": {
+ "selectionSetList": [
+ "title",
+ ],
+ "selectionSetGraphQL": "{\n title\n}",
+ "fieldName": "relatedPosts",
+ "parentTypeName": "Post",
+ },
+ },
+ {
+ "arguments": {},
+ "identity": "None",
+ "source": {
+ "post_id": "1",
+ "title": "First book",
+ },
+ "info": {
+ "selectionSetList": [
+ "title",
+ ],
+ "selectionSetGraphQL": "{\n title\n}",
+ "fieldName": "relatedPosts",
+ "parentTypeName": "Post",
+ },
+ },
+ ]
+
+ # GIVEN A dictionary of posts and a dictionary of related posts.
+ posts = {
+ "1": {
+ "post_id": "1",
+ "title": "First book",
+ },
+ "2": {
+ "post_id": "2",
+ "title": "Second book",
+ },
+ "3": {
+ "post_id": "3",
+ "title": "Third book",
+ },
+ "4": {
+ "post_id": "4",
+ "title": "Fourth book",
+ },
+ }
+
+ posts_related = {
+ "1": [posts["2"]],
+ "2": [posts["3"], posts["4"], posts["1"]],
+ "3": [posts["2"], posts["1"]],
+ "4": [posts["3"], posts["1"]],
+ }
+
+ app = AppSyncResolver()
+
+ @app.batch_resolver(type_name="Post", field_name="relatedPosts", aggregate=False)
+ def related_posts(event: AppSyncResolverEvent) -> list | None:
+ return posts_related[event.source["post_id"]]
+
+ # WHEN related_posts function, which is the batch resolver, is called with the event.
+ result = app.resolve(event, LambdaContext())
+
+ # THEN the result must be a list of related posts
+ assert result == [
+ posts_related["3"],
+ posts_related["4"],
+ posts_related["1"],
+ ]
+
+
+# Batch resolver tests
+def test_resolve_batch_processing_with_simple_queries_one_at_time():
+ # GIVEN a list of events representing GraphQL queries for listing locations
+ event = [
+ {
+ "typeName": "Query",
+ "info": {
+ "fieldName": "listLocations",
+ "parentTypeName": "Post",
+ },
+ "fieldName": "listLocations",
+ "arguments": {},
+ "source": {
+ "id": "1",
+ },
+ },
+ {
+ "typeName": "Query",
+ "info": {
+ "fieldName": "listLocations",
+ "parentTypeName": "Post",
+ },
+ "fieldName": "listLocations",
+ "arguments": {},
+ "source": {
+ "id": "2",
+ },
+ },
+ {
+ "typeName": "Query",
+ "info": {
+ "fieldName": "listLocations",
+ "parentTypeName": "Post",
+ },
+ "fieldName": "listLocations",
+ "arguments": {},
+ "source": {
+ "id": [3, 4],
+ },
+ },
+ ]
+
+ app = AppSyncResolver()
+
+ # WHEN the batch resolver for the listLocations field is defined
+ @app.batch_resolver(field_name="listLocations", aggregate=False)
+ def create_something(event: AppSyncResolverEvent) -> list | None: # noqa AA03 VNE003
+ return event.source["id"] if event.source else None
+
+ # THEN the resolver should correctly process the batch of queries
+ result = app.resolve(event, LambdaContext())
+ assert result == [appsync_event["source"]["id"] for appsync_event in event]
+
+ assert app.current_batch_event and len(app.current_batch_event) == len(event)
+ assert not app.current_event
+
+
+def test_resolve_batch_processing_with_raise_on_exception_one_at_time():
+ # GIVEN a list of events representing GraphQL queries for listing locations
+ event = [
+ {
+ "typeName": "Query",
+ "info": {
+ "fieldName": "listLocations",
+ "parentTypeName": "Post",
+ },
+ "fieldName": "listLocations",
+ "arguments": {},
+ "source": {
+ "id": "1",
+ },
+ },
+ {
+ "typeName": "Query",
+ "info": {
+ "fieldName": "listLocations",
+ "parentTypeName": "Post",
+ },
+ "fieldName": "listLocations",
+ "arguments": {},
+ "source": {
+ "id": "2",
+ },
+ },
+ {
+ "typeName": "Query",
+ "info": {
+ "fieldName": "listLocations",
+ "parentTypeName": "Post",
+ },
+ "fieldName": "listLocations",
+ "arguments": {},
+ "source": {
+ "id": [3, 4],
+ },
+ },
+ ]
+
+ app = AppSyncResolver()
+
+ # WHEN the sync batch resolver for the 'listLocations' field is defined with raise_on_error=True
+ @app.batch_resolver(field_name="listLocations", raise_on_error=True, aggregate=False)
+ def create_something(event: AppSyncResolverEvent) -> list | None: # noqa AA03 VNE003
+ raise RuntimeError
+
+ # THEN the resolver should raise a RuntimeError when processing the batch of queries
+ with pytest.raises(RuntimeError):
+ app.resolve(event, LambdaContext())
+
+
+def test_async_resolve_batch_processing_with_raise_on_exception_one_at_time():
+ # GIVEN a list of events representing GraphQL queries for listing locations
+ event = [
+ {
+ "typeName": "Query",
+ "info": {
+ "fieldName": "listLocations",
+ "parentTypeName": "Post",
+ },
+ "fieldName": "listLocations",
+ "arguments": {},
+ "source": {
+ "id": "1",
+ },
+ },
+ {
+ "typeName": "Query",
+ "info": {
+ "fieldName": "listLocations",
+ "parentTypeName": "Post",
+ },
+ "fieldName": "listLocations",
+ "arguments": {},
+ "source": {
+ "id": "2",
+ },
+ },
+ {
+ "typeName": "Query",
+ "info": {
+ "fieldName": "listLocations",
+ "parentTypeName": "Post",
+ },
+ "fieldName": "listLocations",
+ "arguments": {},
+ "source": {
+ "id": [3, 4],
+ },
+ },
+ ]
+
+ app = AppSyncResolver()
+
+ # WHEN the async batch resolver for the 'listLocations' field is defined with raise_on_error=True
+ @app.async_batch_resolver(field_name="listLocations", raise_on_error=True, aggregate=False)
+ async def create_something(event: AppSyncResolverEvent) -> list | None: # noqa AA03 VNE003
+ raise RuntimeError
+
+ # THEN the resolver should raise a RuntimeError when processing the batch of queries
+ with pytest.raises(RuntimeError):
+ app.resolve(event, LambdaContext())
+
+
+def test_resolve_batch_processing_without_exception_one_at_time():
+ event = [
+ {
+ "typeName": "Query",
+ "info": {
+ "fieldName": "listLocations",
+ "parentTypeName": "Post",
+ },
+ "fieldName": "listLocations",
+ "arguments": {},
+ "source": {
+ "id": "1",
+ },
+ },
+ {
+ "typeName": "Query",
+ "info": {
+ "fieldName": "listLocations",
+ "parentTypeName": "Post",
+ },
+ "fieldName": "listLocations",
+ "arguments": {},
+ "source": {
+ "id": "2",
+ },
+ },
+ {
+ "typeName": "Query",
+ "info": {
+ "fieldName": "listLocations",
+ "parentTypeName": "Post",
+ },
+ "fieldName": "listLocations",
+ "arguments": {},
+ "source": {
+ "id": [3, 4],
+ },
+ },
+ ]
+
+ app = AppSyncResolver()
+
+ @app.batch_resolver(field_name="listLocations", raise_on_error=False, aggregate=False)
+ def create_something(event: AppSyncResolverEvent) -> list | None: # noqa AA03 VNE003
+ raise RuntimeError
+
+ # Call the implicit handler
+ result = app.resolve(event, LambdaContext())
+ assert result == [None, None, None]
+
+ assert app.current_batch_event and len(app.current_batch_event) == len(event)
+ assert not app.current_event
+
+
+def test_resolve_async_batch_processing_without_exception_one_at_time():
+ # GIVEN a list of events representing GraphQL queries for listing locations
+ event = [
+ {
+ "typeName": "Query",
+ "info": {
+ "fieldName": "listLocations",
+ "parentTypeName": "Post",
+ },
+ "fieldName": "listLocations",
+ "arguments": {},
+ "source": {
+ "id": "1",
+ },
+ },
+ {
+ "typeName": "Query",
+ "info": {
+ "fieldName": "listLocations",
+ "parentTypeName": "Post",
+ },
+ "fieldName": "listLocations",
+ "arguments": {},
+ "source": {
+ "id": "2",
+ },
+ },
+ {
+ "typeName": "Query",
+ "info": {
+ "fieldName": "listLocations",
+ "parentTypeName": "Post",
+ },
+ "fieldName": "listLocations",
+ "arguments": {},
+ "source": {
+ "id": [3, 4],
+ },
+ },
+ ]
+
+ app = AppSyncResolver()
+
+ # WHEN the batch resolver for the 'listLocations' field is defined with raise_on_error=False
+ @app.async_batch_resolver(field_name="listLocations", raise_on_error=False, aggregate=False)
+ async def create_something(event: AppSyncResolverEvent) -> list | None: # noqa AA03 VNE003
+ raise RuntimeError
+
+ result = app.resolve(event, LambdaContext())
+
+ # THEN the resolver should return None for each event in the batch
+ assert len(app.current_batch_event) == len(event)
+ assert result == [None, None, None]
+
+
+def test_resolver_batch_with_resolver_not_found_one_at_time():
+ # GIVEN a AppSyncResolver
+ app = AppSyncResolver()
+ router = Router()
+
+ # WHEN we have an event
+ # WHEN the event field_name doesn't match with the resolver field_name
+ mock_event1 = [
+ {
+ "typeName": "Query",
+ "info": {
+ "fieldName": "listCars",
+ "parentTypeName": "Query",
+ },
+ "fieldName": "listCars",
+ "arguments": {"name": "value"},
+ "source": {
+ "id": "1",
+ },
+ },
+ ]
+
+ @router.batch_resolver(type_name="Query", field_name="listLocations", aggregate=False)
+ def get_locations(event: AppSyncResolverEvent, name: str) -> str:
+ return f"get_locations#{name}#" + event.source["id"]
+
+ app.include_router(router)
+
+ # THEN must fail with ResolverNotFoundError
+ with pytest.raises(ResolverNotFoundError, match="No resolver found for.*"):
+ app.resolve(mock_event1, LambdaContext())
+
+
+def test_resolver_batch_with_sync_and_async_resolver_at_same_time():
+ # GIVEN a AppSyncResolver
+ app = AppSyncResolver()
+ router = Router()
+
+ # WHEN we have an event
+ # WHEN the event field_name doesn't match with the resolver field_name
+ mock_event1 = [
+ {
+ "typeName": "Query",
+ "info": {
+ "fieldName": "listCars",
+ "parentTypeName": "Query",
+ },
+ "fieldName": "listCars",
+ "arguments": {"name": "value"},
+ "source": {
+ "id": "1",
+ },
+ },
+ ]
+
+ @router.batch_resolver(type_name="Query", field_name="listCars", aggregate=False)
+ def get_locations(event: AppSyncResolverEvent, name: str) -> str:
+ return f"get_locations#{name}#" + event.source["id"]
+
+ @router.async_batch_resolver(type_name="Query", field_name="listCars", aggregate=False)
+ async def get_locations_async(event: AppSyncResolverEvent, name: str) -> str:
+ return f"get_locations#{name}#" + event.source["id"]
+
+ app.include_router(router)
+
+ # THEN must raise a PowertoolsUserWarning
+ with pytest.warns(PowertoolsUserWarning, match="Both synchronous and asynchronous resolvers*"):
+ app.resolve(mock_event1, LambdaContext())
+
+
+def test_batch_resolver_with_router():
+ # GIVEN an AppSyncResolver and a Router instance
+ app = AppSyncResolver()
+ router = Router()
+
+ @router.batch_resolver(type_name="Query", field_name="listLocations", aggregate=False)
+ def get_locations(event: AppSyncResolverEvent, name: str) -> str:
+ return f"get_locations#{name}#" + event.source["id"]
+
+ @router.batch_resolver(field_name="listLocations2", aggregate=False)
+ def get_locations2(event: AppSyncResolverEvent, name: str) -> str:
+ return f"get_locations2#{name}#" + event.source["id"]
+
+ # WHEN we include the routes
+ app.include_router(router)
+
+ mock_event1 = [
+ {
+ "typeName": "Query",
+ "info": {
+ "fieldName": "listLocations",
+ "parentTypeName": "Query",
+ },
+ "fieldName": "listLocations",
+ "arguments": {"name": "value"},
+ "source": {
+ "id": "1",
+ },
+ },
+ ]
+ mock_event2 = [
+ {
+ "typeName": "Query",
+ "info": {
+ "fieldName": "listLocations2",
+ "parentTypeName": "Post",
+ },
+ "fieldName": "listLocations2",
+ "arguments": {"name": "value"},
+ "source": {
+ "id": "2",
+ },
+ },
+ ]
+ result1 = app.resolve(mock_event1, LambdaContext())
+ result2 = app.resolve(mock_event2, LambdaContext())
+
+ # THEN the resolvers should return the expected results
+ assert result1 == ["get_locations#value#1"]
+ assert result2 == ["get_locations2#value#2"]
+
+
+def test_resolve_async_batch_processing():
+ # GIVEN a list of events representing GraphQL queries for listing locations
+ event = [
+ {
+ "typeName": "Query",
+ "info": {
+ "fieldName": "listLocations",
+ "parentTypeName": "Post",
+ },
+ "fieldName": "listLocations",
+ "arguments": {},
+ "source": {
+ "id": "1",
+ },
+ },
+ {
+ "typeName": "Query",
+ "info": {
+ "fieldName": "listLocations",
+ "parentTypeName": "Post",
+ },
+ "fieldName": "listLocations",
+ "arguments": {},
+ "source": {
+ "id": "2",
+ },
+ },
+ {
+ "typeName": "Query",
+ "info": {
+ "fieldName": "listLocations",
+ "parentTypeName": "Post",
+ },
+ "fieldName": "listLocations",
+ "arguments": {},
+ "source": {
+ "id": [3, 4],
+ },
+ },
+ ]
+
+ app = AppSyncResolver()
+
+ # WHEN the async batch resolver for the 'listLocations' field is defined
+ @app.async_batch_resolver(field_name="listLocations", aggregate=False)
+ async def create_something(event: AppSyncResolverEvent) -> list | None:
+ return event.source["id"] if event.source else None
+
+ # THEN the resolver should correctly process the batch of queries asynchronously
+ result = app.resolve(event, LambdaContext())
+ assert result == [appsync_event["source"]["id"] for appsync_event in event]
+
+ assert app.current_batch_event and len(app.current_batch_event) == len(event)
+
+
+def test_resolve_async_batch_and_sync_singular_processing():
+ # GIVEN a router with an async batch resolver for 'listLocations' and a sync singular resolver for 'listLocation'
+ app = AppSyncResolver()
+ router = Router()
+
+ @router.async_batch_resolver(type_name="Query", field_name="listLocations", aggregate=False)
+ async def get_locations(event: AppSyncResolverEvent, name: str) -> str:
+ return f"get_locations#{name}#" + event.source["id"]
+
+ @app.resolver(type_name="Query", field_name="listLocation")
+ def get_location(name: str) -> str:
+ return f"get_location#{name}"
+
+ app.include_router(router)
+
+ # WHEN resolving a batch of events for async 'listLocations' and a singular event for 'listLocation'
+ mock_event1 = [
+ {
+ "typeName": "Query",
+ "info": {
+ "fieldName": "listLocations",
+ "parentTypeName": "Query",
+ },
+ "fieldName": "listLocations",
+ "arguments": {"name": "value"},
+ "source": {
+ "id": "1",
+ },
+ },
+ ]
+ mock_event2 = {"typeName": "Query", "fieldName": "listLocation", "arguments": {"name": "value"}}
+
+ result1 = app.resolve(mock_event1, LambdaContext())
+ result2 = app.resolve(mock_event2, LambdaContext())
+
+ # THEN the resolvers should return the expected results
+ assert result1 == ["get_locations#value#1"]
+ assert result2 == "get_location#value"
+
+
+def test_async_resolver_include_batch_resolver():
+ # GIVEN an AppSyncResolver instance and a Router
+ app = AppSyncResolver()
+ router = Router()
+
+ @router.async_batch_resolver(type_name="Query", field_name="listLocations", aggregate=False)
+ async def get_locations(event: AppSyncResolverEvent, name: str) -> str:
+ return f"get_locations#{name}#" + event.source["id"]
+
+ @app.async_batch_resolver(field_name="listLocations2", aggregate=False)
+ async def get_locations2(event: AppSyncResolverEvent, name: str) -> str:
+ return f"get_locations2#{name}#" + event.source["id"]
+
+ app.include_router(router)
+
+ # WHEN two different events needs to be resolved
+ mock_event1 = [
+ {
+ "typeName": "Query",
+ "info": {
+ "fieldName": "listLocations",
+ "parentTypeName": "Query",
+ },
+ "fieldName": "listLocations",
+ "arguments": {"name": "value"},
+ "source": {
+ "id": "1",
+ },
+ },
+ ]
+ mock_event2 = [
+ {
+ "typeName": "Query",
+ "info": {
+ "fieldName": "listLocations2",
+ "parentTypeName": "Post",
+ },
+ "fieldName": "listLocations2",
+ "arguments": {"name": "value"},
+ "source": {
+ "id": "2",
+ },
+ },
+ ]
+
+ # WHEN Resolve the events using the AppSyncResolver
+ result1 = app.resolve(mock_event1, LambdaContext())
+ result2 = app.resolve(mock_event2, LambdaContext())
+
+ # THEN Verify that the results match the expected values
+ assert result1 == ["get_locations#value#1"]
+ assert result2 == ["get_locations2#value#2"]
+
+
+def test_resolve_batch_processing_with_simple_queries_with_aggregate():
+ # GIVEN a list of events representing GraphQL queries for listing locations
+ event = [
+ {
+ "typeName": "Query",
+ "info": {
+ "fieldName": "listLocations",
+ "parentTypeName": "Post",
+ },
+ "fieldName": "listLocations",
+ "arguments": {},
+ "source": {
+ "id": "1",
+ },
+ },
+ {
+ "typeName": "Query",
+ "info": {
+ "fieldName": "listLocations",
+ "parentTypeName": "Post",
+ },
+ "fieldName": "listLocations",
+ "arguments": {},
+ "source": {
+ "id": "2",
+ },
+ },
+ {
+ "typeName": "Query",
+ "info": {
+ "fieldName": "listLocations",
+ "parentTypeName": "Post",
+ },
+ "fieldName": "listLocations",
+ "arguments": {},
+ "source": {
+ "id": [3, 4],
+ },
+ },
+ ]
+
+ app = AppSyncResolver()
+
+ # WHEN the sync batch resolver for the listLocations field is defined
+ # WHEN using an aggregated event
+ # WHEN function returns a List
+ @app.batch_resolver(field_name="listLocations")
+ def create_something(event: list[AppSyncResolverEvent]) -> list: # noqa AA03 VNE003
+ results = []
+ for record in event:
+ results.append(record.source.get("id") if record.source else None)
+
+ return results
+
+ # THEN the resolver should correctly process the batch of queries
+ result = app.resolve(event, LambdaContext())
+ assert result == [appsync_event["source"]["id"] for appsync_event in event]
+
+ assert app.current_batch_event and len(app.current_batch_event) == len(event)
+
+
+def test_resolve_async_batch_processing_with_simple_queries_with_aggregate():
+ # GIVEN a list of events representing GraphQL queries for listing locations
+ event = [
+ {
+ "typeName": "Query",
+ "info": {
+ "fieldName": "listLocations",
+ "parentTypeName": "Post",
+ },
+ "fieldName": "listLocations",
+ "arguments": {},
+ "source": {
+ "id": "1",
+ },
+ },
+ {
+ "typeName": "Query",
+ "info": {
+ "fieldName": "listLocations",
+ "parentTypeName": "Post",
+ },
+ "fieldName": "listLocations",
+ "arguments": {},
+ "source": {
+ "id": "2",
+ },
+ },
+ {
+ "typeName": "Query",
+ "info": {
+ "fieldName": "listLocations",
+ "parentTypeName": "Post",
+ },
+ "fieldName": "listLocations",
+ "arguments": {},
+ "source": {
+ "id": [3, 4],
+ },
+ },
+ ]
+
+ app = AppSyncResolver()
+
+ # WHEN the async batch resolver for the listLocations field is defined
+ # WHEN using an aggregated event
+ # WHEN function returns a List
+ @app.async_batch_resolver(field_name="listLocations")
+ async def create_something(event: list[AppSyncResolverEvent]) -> list: # noqa AA03 VNE003
+ results = []
+ for record in event:
+ results.append(record.source.get("id") if record.source else None)
+
+ return results
+
+ # THEN the resolver should correctly process the batch of queries
+ result = app.resolve(event, LambdaContext())
+ assert result == [appsync_event["source"]["id"] for appsync_event in event]
+
+ assert app.current_batch_event and len(app.current_batch_event) == len(event)
+
+
+def test_resolve_batch_processing_with_aggregate_and_returning_a_non_list():
+ # GIVEN a list of events representing GraphQL queries for listing locations
+ event = [
+ {
+ "typeName": "Query",
+ "info": {
+ "fieldName": "listLocations",
+ "parentTypeName": "Post",
+ },
+ "fieldName": "listLocations",
+ "arguments": {},
+ "source": {
+ "id": "1",
+ },
+ },
+ ]
+
+ app = AppSyncResolver()
+
+ # WHEN the sync batch resolver for the listLocations field is defined
+ # WHEN using an aggregated event
+ # WHEN function return something different than a List
+ @app.batch_resolver(field_name="listLocations")
+ def create_something(event: list[AppSyncResolverEvent]) -> list | None: # noqa AA03 VNE003
+ return event[0].source.get("id") if event[0].source else None
+
+ # THEN the resolver should raise a InvalidBatchResponse when processing the batch of queries
+ with pytest.raises(InvalidBatchResponse):
+ app.resolve(event, LambdaContext())
+
+
+def test_resolve_async_batch_processing_with_aggregate_and_returning_a_non_list():
+ # GIVEN a list of events representing GraphQL queries for listing locations
+ event = [
+ {
+ "typeName": "Query",
+ "info": {
+ "fieldName": "listLocations",
+ "parentTypeName": "Post",
+ },
+ "fieldName": "listLocations",
+ "arguments": {},
+ "source": {
+ "id": "1",
+ },
+ },
+ ]
+
+ app = AppSyncResolver()
+
+ # WHEN the async batch resolver for the listLocations field is defined
+ # WHEN using an aggregated event
+ # WHEN function return something different than a List
+ @app.async_batch_resolver(field_name="listLocations")
+ async def create_something(event: list[AppSyncResolverEvent]) -> list | None: # noqa AA03 VNE003
+ return event[0].source.get("id") if event[0].source else None
+
+ # THEN the resolver should raise a InvalidBatchResponse when processing the batch of queries
+ with pytest.raises(InvalidBatchResponse):
+ app.resolve(event, LambdaContext())
+
+
+def test_resolve_sync_batch_processing_with_aggregate_and_without_return():
+ # GIVEN a list of events representing GraphQL queries for listing locations
+ event = [
+ {
+ "typeName": "Query",
+ "info": {
+ "fieldName": "listLocations",
+ "parentTypeName": "Post",
+ },
+ "fieldName": "listLocations",
+ "arguments": {},
+ "source": {
+ "id": "1",
+ },
+ },
+ ]
+
+ app = AppSyncResolver()
+
+ # WHEN the sync batch resolver for the listLocations field is defined
+ # WHEN using an aggregated event
+ # WHEN function there is no return statement
+ @app.batch_resolver(field_name="listLocations")
+ def create_something(event: list[AppSyncResolverEvent]) -> list | None: # noqa AA03 VNE003
+ def do_something_with_post_id(post_id): ...
+
+ post_id = event[0].source.get("id") if event[0].source else None
+ do_something_with_post_id(post_id)
+
+ # No Return statement
+
+ # THEN the resolver should raise a InvalidBatchResponse when processing the batch of queries
+ with pytest.raises(InvalidBatchResponse):
+ app.resolve(event, LambdaContext())
+
+
+def test_resolve_async_batch_processing_with_aggregate_and_without_return():
+ # GIVEN a list of events representing GraphQL queries for listing locations
+ event = [
+ {
+ "typeName": "Query",
+ "info": {
+ "fieldName": "listLocations",
+ "parentTypeName": "Post",
+ },
+ "fieldName": "listLocations",
+ "arguments": {},
+ "source": {
+ "id": "1",
+ },
+ },
+ ]
+
+ app = AppSyncResolver()
+
+ # WHEN the async batch resolver for the listLocations field is defined
+ # WHEN using an aggregated event
+ # WHEN function there is no return statement
+ @app.async_batch_resolver(field_name="listLocations")
+ async def create_something(event: list[AppSyncResolverEvent]) -> list | None: # noqa AA03 VNE003
+ def do_something_with_post_id(post_id): ...
+
+ post_id = event[0].source.get("id") if event[0].source else None
+ do_something_with_post_id(post_id)
+
+ # No Return statement
+
+ # THEN the resolver should raise a InvalidBatchResponse when processing the batch of queries
+ with pytest.raises(InvalidBatchResponse):
+ app.resolve(event, LambdaContext())
+
+
+def test_include_router_access_batch_current_event():
+ mock_event = load_event("appSyncBatchEvent.json")
+
+ # GIVEN An instance of AppSyncResolver, a Router instance, and a resolver function registered with the router
+ app = AppSyncResolver()
+ router = Router()
+
+ @router.batch_resolver(field_name="createSomething")
+ def get_user(event: list) -> list:
+ return [router.current_batch_event[0].identity.sub]
+
+ app.include_router(router)
+
+ # WHEN we resolve the event
+ ret = app.resolve(mock_event, {})
+
+ # THEN the resolver must be able to return a field in the batch_current_event
+ assert ret[0] == mock_event[0]["identity"]["sub"]
+
+
+def test_app_access_batch_current_event():
+ mock_event = load_event("appSyncBatchEvent.json")
+
+ # GIVEN An instance of AppSyncResolver and a resolver function registered with the app
+ app = AppSyncResolver()
+
+ @app.batch_resolver(field_name="createSomething")
+ def get_user(event: list) -> list:
+ return [app.current_batch_event[0].identity.sub]
+
+ # WHEN we resolve the event
+ ret = app.resolve(mock_event, {})
+
+ # THEN the resolver must be able to return a field in the batch_current_event
+ assert ret[0] == mock_event[0]["identity"]["sub"]
+
+
+def test_context_is_accessible_in_sync_batch_resolver():
+ mock_event = load_event("appSyncBatchEvent.json")
+
+ # GIVEN An instance of AppSyncResolver and a resolver function registered with the app
+ app = AppSyncResolver()
+
+ @app.batch_resolver(field_name="createSomething")
+ def get_user(event: list) -> list:
+ return [app.context.get("project_name")]
+
+ # WHEN we resolve the event
+ app.append_context(project_name="powertools")
+ ret = app.resolve(mock_event, {})
+
+ # THEN the resolver must be able to return a field in the batch_current_event
+ assert app.context == {}
+ assert ret[0] == "powertools"
+
+
+def test_context_is_accessible_in_async_batch_resolver():
+ mock_event = load_event("appSyncBatchEvent.json")
+
+ # GIVEN An instance of AppSyncResolver and a resolver function registered with the app
+ app = AppSyncResolver()
+
+ @app.async_batch_resolver(field_name="createSomething")
+ async def get_user(event: list) -> list:
+ return [app.context.get("project_name")]
+
+ # WHEN we resolve the event
+ app.append_context(project_name="powertools")
+ ret = app.resolve(mock_event, {})
+
+ # THEN the resolver must be able to return a field in the batch_current_event
+ assert app.context == {}
+ assert ret[0] == "powertools"
+
+
+def test_exception_handler_with_batch_resolver_and_raise_exception():
+ # GIVEN a AppSyncResolver instance
+ app = AppSyncResolver()
+
+ event = [
+ {
+ "typeName": "Query",
+ "info": {
+ "fieldName": "listLocations",
+ "parentTypeName": "Post",
+ },
+ "fieldName": "listLocations",
+ "arguments": {},
+ "source": {
+ "id": "1",
+ },
+ },
+ {
+ "typeName": "Query",
+ "info": {
+ "fieldName": "listLocations",
+ "parentTypeName": "Post",
+ },
+ "fieldName": "listLocations",
+ "arguments": {},
+ "source": {
+ "id": "2",
+ },
+ },
+ {
+ "typeName": "Query",
+ "info": {
+ "fieldName": "listLocations",
+ "parentTypeName": "Post",
+ },
+ "fieldName": "listLocations",
+ "arguments": {},
+ "source": {
+ "id": [3, 4],
+ },
+ },
+ ]
+
+ # WHEN we configure exception handler for ValueError
+ @app.exception_handler(ValueError)
+ def handle_value_error(ex: ValueError):
+ return {"message": "error"}
+
+ # WHEN the sync batch resolver for the 'listLocations' field is defined with raise_on_error=True
+ @app.batch_resolver(field_name="listLocations", raise_on_error=True, aggregate=False)
+ def create_something(event: AppSyncResolverEvent) -> list | None: # noqa AA03 VNE003
+ raise ValueError
+
+ # Call the implicit handler
+ result = app(event, {})
+
+ # THEN the return must be the Exception Handler error message
+ assert result["message"] == "error"
+
+
+def test_exception_handler_with_batch_resolver_and_no_raise_exception():
+ # GIVEN a AppSyncResolver instance
+ app = AppSyncResolver()
+
+ event = [
+ {
+ "typeName": "Query",
+ "info": {
+ "fieldName": "listLocations",
+ "parentTypeName": "Post",
+ },
+ "fieldName": "listLocations",
+ "arguments": {},
+ "source": {
+ "id": "1",
+ },
+ },
+ {
+ "typeName": "Query",
+ "info": {
+ "fieldName": "listLocations",
+ "parentTypeName": "Post",
+ },
+ "fieldName": "listLocations",
+ "arguments": {},
+ "source": {
+ "id": "2",
+ },
+ },
+ {
+ "typeName": "Query",
+ "info": {
+ "fieldName": "listLocations",
+ "parentTypeName": "Post",
+ },
+ "fieldName": "listLocations",
+ "arguments": {},
+ "source": {
+ "id": [3, 4],
+ },
+ },
+ ]
+
+ # WHEN we configure exception handler for ValueError
+ @app.exception_handler(ValueError)
+ def handle_value_error(ex: ValueError):
+ return {"message": "error"}
+
+ # WHEN the sync batch resolver for the 'listLocations' field is defined with raise_on_error=False
+ @app.batch_resolver(field_name="listLocations", raise_on_error=False, aggregate=False)
+ def create_something(event: AppSyncResolverEvent) -> list | None: # noqa AA03 VNE003
+ raise ValueError
+
+ # Call the implicit handler
+ result = app(event, {})
+
+ # THEN the return must not trigger the Exception Handler, but instead return from the resolver
+ assert result == [None, None, None]
diff --git a/tests/functional/event_handler/required_dependencies/appsync/test_appsync_events_resolvers.py b/tests/functional/event_handler/required_dependencies/appsync/test_appsync_events_resolvers.py
new file mode 100644
index 00000000000..4d53c3cb934
--- /dev/null
+++ b/tests/functional/event_handler/required_dependencies/appsync/test_appsync_events_resolvers.py
@@ -0,0 +1,1614 @@
+import asyncio
+from copy import deepcopy
+
+import pytest
+
+from aws_lambda_powertools.event_handler import AppSyncEventsResolver
+from aws_lambda_powertools.event_handler.events_appsync.exceptions import UnauthorizedException
+from aws_lambda_powertools.event_handler.events_appsync.router import Router
+from aws_lambda_powertools.warnings import PowertoolsUserWarning
+from tests.functional.utils import load_event
+
+
+class LambdaContext:
+ def __init__(self):
+ self.function_name = "test-func"
+ self.memory_limit_in_mb = 128
+ self.invoked_function_arn = "arn:aws:lambda:eu-west-1:809313241234:function:test-func"
+ self.aws_request_id = "52fdfc07-2182-154f-163f-5f0f9a621d72"
+
+ def get_remaining_time_in_millis(self) -> int:
+ return 1000
+
+
+@pytest.fixture(scope="module")
+def lambda_context() -> LambdaContext:
+ """Create a new LambdaContext instance for each test module."""
+ return LambdaContext()
+
+
+@pytest.fixture(scope="module")
+def mock_event():
+ """Load a sample AppSyncEventsEvent for each test module."""
+ return load_event("appSyncEventsEvent.json")
+
+
+def test_publish_event_with_synchronous_resolver(lambda_context, mock_event):
+ """Test handling a publish event with a synchronous resolver."""
+ # GIVEN a sample publish event
+ mock_event["events"] = [
+ {"id": "123", "payload": {"data": "test data"}},
+ ]
+
+ # GIVEN an AppSyncEventsResolver with a synchronous resolver
+ app = AppSyncEventsResolver()
+
+ @app.on_publish(path="/default/*")
+ def test_handler(payload):
+ return {"processed": True, "data": payload["data"]}
+
+ # WHEN we resolve the event
+ result = app.resolve(mock_event, lambda_context)
+
+ # THEN we should get the correct response
+ expected_result = {
+ "events": [
+ {"id": "123", "payload": {"processed": True, "data": "test data"}},
+ ],
+ }
+ assert result == expected_result
+
+
+def test_publish_event_with_async_resolver(lambda_context, mock_event):
+ """Test handling a publish event with an asynchronous resolver."""
+ # GIVEN a sample publish event
+ mock_event["events"] = [
+ {"id": "123", "payload": {"data": "test data"}},
+ ]
+
+ # GIVEN an AppSyncEventsResolver with an asynchronous resolver
+ app = AppSyncEventsResolver()
+
+ @app.async_on_publish(path="/default/*")
+ async def test_handler(payload):
+ await asyncio.sleep(0.01) # Simulate async work
+ return {"processed": True, "data": payload["data"]}
+
+ # WHEN we resolve the event
+ result = app.resolve(mock_event, lambda_context)
+
+ # THEN we should get the correct response
+ assert "events" in result
+ assert len(result["events"]) == 1
+ assert result["events"][0]["payload"]["processed"] is True
+ assert result["events"][0]["payload"]["data"] == "test data"
+
+
+def test_publish_event_with_error_handling(lambda_context, mock_event):
+ """Test error handling during publish event processing."""
+ # GIVEN a sample publish event
+ mock_event["events"] = [
+ {"id": "123", "payload": {"data": "test data"}},
+ ]
+
+ # GIVEN an AppSyncEventsResolver with a resolver that raises an exception
+ app = AppSyncEventsResolver()
+
+ @app.on_publish(path="/default/*")
+ def test_handler(payload):
+ raise ValueError("Test error")
+
+ # WHEN we resolve the event
+ result = app.resolve(mock_event, lambda_context)
+
+ # THEN we should get an error response
+ assert "events" in result
+ assert "error" in result["events"][0]
+ assert "ValueError - Test error" in result["events"][0]["error"]
+ assert result["events"][0]["id"] == "123"
+
+
+def test_publish_event_with_router_inclusion(lambda_context, mock_event):
+ """Test including a router in the AppSyncEventsResolver."""
+ # GIVEN a sample publish event
+ mock_event["events"] = [
+ {"id": "123", "payload": {"data": "test data", "from_router": True}},
+ ]
+
+ # GIVEN a router with a resolver
+ router = Router()
+
+ @router.on_publish(path="/chat/*")
+ def router_handler(payload):
+ return {"from_router": True, "data": payload["data"]}
+
+ # GIVEN an AppSyncEventsResolver that includes the router
+ app = AppSyncEventsResolver()
+ app.include_router(router)
+
+ # WHEN we resolve the event
+ result = app.resolve(mock_event, lambda_context)
+
+ # THEN we should get the response from the router's handler
+ expected_result = {
+ "events": [
+ {"id": "123", "payload": {"from_router": True, "data": "test data"}},
+ ],
+ }
+ assert result == expected_result
+
+
+def test_publish_event_with_custom_context(lambda_context, mock_event):
+ """Test resolving events with custom context data."""
+ # GIVEN a sample publish event
+ mock_event["events"] = [
+ {"id": "123", "payload": {"data": "test data"}},
+ ]
+
+ # GIVEN an AppSyncEventsResolver with custom context
+ app = AppSyncEventsResolver()
+
+ @app.on_publish(path="/default/*")
+ def test_handler(payload):
+ # Access the context within the handler
+ return {
+ "processed": True,
+ "data": payload["data"],
+ "user_id": app.context.get("user_id"),
+ "role": app.context.get("role"),
+ }
+
+ # WHEN we resolve the event
+ app.append_context(user_id="test-user", role="admin")
+ result = app.resolve(mock_event, lambda_context)
+
+ # THEN we should get the response with context data
+ expected_result = {
+ "events": [
+ {
+ "id": "123",
+ "payload": {
+ "processed": True,
+ "data": "test data",
+ "user_id": "test-user",
+ "role": "admin",
+ },
+ },
+ ],
+ }
+ assert result == expected_result
+
+
+def test_publish_event_with_aggregate_mode(lambda_context, mock_event):
+ """Test handling a publish event with aggregate mode enabled."""
+ # GIVEN a sample publish event with multiple items
+ mock_event["events"] = [
+ {"id": "123", "payload": {"data": "test data 1"}},
+ {"id": "456", "payload": {"data": "test data 2"}},
+ ]
+
+ # GIVEN an AppSyncEventsResolver with an aggregate resolver
+ app = AppSyncEventsResolver()
+
+ @app.on_publish(path="/default/*", aggregate=True)
+ def test_batch_handler(payload):
+ # Process all events at once
+ return [{"batch_processed": True, "data": item["payload"]["data"]} for item in payload]
+
+ # WHEN we resolve the event
+ result = app.resolve(mock_event, lambda_context)
+
+ # THEN we should get the batch processed response
+ expected_result = {
+ "events": [
+ {"batch_processed": True, "data": "test data 1"},
+ {"batch_processed": True, "data": "test data 2"},
+ ],
+ }
+ assert result == expected_result
+
+
+def test_async_publish_event_with_aggregate_mode(lambda_context, mock_event):
+ """Test handling an async publish event with aggregate mode enabled."""
+ # GIVEN a sample publish event with multiple items
+ mock_event["events"] = [
+ {"id": "123", "payload": {"data": "test data 1"}},
+ {"id": "456", "payload": {"data": "test data 2"}},
+ ]
+
+ # GIVEN an AppSyncEventsResolver with an async aggregate resolver
+ app = AppSyncEventsResolver()
+
+ @app.async_on_publish(path="/default/*", aggregate=True)
+ async def test_async_batch_handler(payload):
+ # Simulate async processing of all events
+ await asyncio.sleep(0.01)
+ return [{"async_batch_processed": True, "data": item["payload"]["data"]} for item in payload]
+
+ # WHEN we resolve the event
+ result = app.resolve(mock_event, lambda_context)
+
+ # THEN we should get the batch processed response
+ expected_result = {
+ "events": [
+ {"async_batch_processed": True, "data": "test data 1"},
+ {"async_batch_processed": True, "data": "test data 2"},
+ ],
+ }
+ assert result == expected_result
+
+
+def test_publish_event_no_matching_resolver(lambda_context, mock_event):
+ """Test handling a publish event when no matching resolver is found."""
+ # GIVEN a sample publish event
+ mock_event["info"]["channel"]["path"] = "/unknown/path"
+ mock_event["events"] = [
+ {"id": "123", "payload": {"data": "test data"}},
+ ]
+
+ # GIVEN an AppSyncEventsResolver with no matching resolver
+ app = AppSyncEventsResolver()
+
+ @app.on_publish(path="/default/*")
+ def test_handler(payload):
+ return {"processed": True}
+
+ # WHEN we resolve the event with a warning
+ with pytest.warns(PowertoolsUserWarning, match="No resolvers were found for publish operations"):
+ result = app.resolve(mock_event, lambda_context)
+
+ # THEN we should get the original payload returned as is
+ expected_result = {
+ "events": [
+ {"id": "123", "payload": {"data": "test data"}},
+ ],
+ }
+ assert result == expected_result
+
+
+def test_multiple_resolvers_for_same_path(lambda_context, mock_event):
+ """Test behavior when both sync and async resolvers exist for the same path."""
+ # GIVEN a sample publish event
+ mock_event["info"]["channel"]["path"] = "/default/test"
+ mock_event["events"] = [
+ {"id": "123", "payload": {"sync_processed": True, "data": "test data"}},
+ ]
+
+ # GIVEN an AppSyncEventsResolver with both sync and async resolvers for the same path
+ app = AppSyncEventsResolver()
+
+ @app.on_publish(path="/default/*")
+ def sync_handler(payload):
+ return {"sync_processed": True, "data": payload["data"]}
+
+ @app.async_on_publish(path="/default/*")
+ async def async_handler(event):
+ await asyncio.sleep(0.01)
+ return {"async_processed": True, "data": event["data"]}
+
+ # WHEN we resolve the event, with a warning expected
+ with pytest.warns(PowertoolsUserWarning, match="Both synchronous and asynchronous resolvers found"):
+ result = app.resolve(mock_event, lambda_context)
+
+ # THEN the sync resolver should be used (takes precedence)
+ expected_result = {
+ "events": [
+ {"id": "123", "payload": {"sync_processed": True, "data": "test data"}},
+ ],
+ }
+ assert result == expected_result
+
+
+def test_custom_exception_handling(lambda_context, mock_event):
+ """Test handling custom exceptions during event processing."""
+ # GIVEN a sample publish event
+ mock_event["events"] = [
+ {"id": "123", "payload": {"sync_processed": True, "data": "test data"}},
+ ]
+
+ # GIVEN a custom exception class
+ class NotAuthorized(Exception):
+ pass
+
+ # GIVEN an AppSyncEventsResolver with a resolver that raises a custom exception
+ app = AppSyncEventsResolver()
+
+ @app.on_publish(path="/default/*")
+ def test_handler(payload):
+ if payload["data"] == "test data":
+ raise NotAuthorized("Not authorized")
+ return {"processed": True}
+
+ # WHEN we resolve the event
+ result = app.resolve(mock_event, lambda_context)
+
+ # THEN we should get an error response with our custom exception
+ assert "events" in result
+ assert "error" in result["events"][0]
+ assert "NotAuthorized - Not authorized" in result["events"][0]["error"]
+ assert result["events"][0]["id"] == "123"
+
+
+def test_async_resolver_with_error_handling(lambda_context, mock_event):
+ """Test error handling with async resolvers during publish event processing."""
+ # GIVEN a sample publish event
+ mock_event["events"] = [
+ {"id": "123", "payload": {"sync_processed": True, "data": "test data"}},
+ ]
+
+ # GIVEN an AppSyncEventsResolver with an async resolver that raises an exception
+ app = AppSyncEventsResolver()
+
+ @app.async_on_publish(path="/default/*")
+ async def test_handler(payload):
+ await asyncio.sleep(0.01) # Simulate async work
+ raise ValueError("Async test error")
+
+ # WHEN we resolve the event
+ result = app.resolve(mock_event, lambda_context)
+
+ # THEN we should get an error response
+ assert "events" in result
+ assert len(result["events"]) == 1
+ assert "error" in result["events"][0]
+ assert "ValueError - Async test error" in result["events"][0]["error"]
+
+
+def test_lambda_handler_with_call_method(lambda_context, mock_event):
+ """Test that the lambda handler function properly processes events."""
+ # GIVEN a sample publish event
+ mock_event["events"] = [
+ {"id": "123", "payload": {"sync_processed": True, "data": "test data"}},
+ ]
+
+ # GIVEN an AppSyncEventsResolver setup
+ app = AppSyncEventsResolver()
+
+ @app.on_publish(path="/default/*")
+ def test_handler(payload):
+ return {"lambda_processed": True, "data": payload["data"]}
+
+ # WHEN we use the AppSyncEventsResolver as a Lambda handler
+ result = app(mock_event, lambda_context) # Using __call__ method which calls resolve()
+
+ # THEN we should get the processed response
+ expected_result = {
+ "events": [
+ {"id": "123", "payload": {"lambda_processed": True, "data": "test data"}},
+ ],
+ }
+ assert result == expected_result
+
+
+def test_event_with_mixed_success_and_errors(lambda_context, mock_event):
+ """Test handling a batch of events with mixed success and failure outcomes."""
+ # GIVEN a sample publish event with multiple items
+ mock_event["events"] = [
+ {"id": "123", "payload": {"data": "good data"}},
+ {"id": "456", "payload": {"data": "bad data"}},
+ {"id": "789", "payload": {"data": "good data again"}},
+ ]
+
+ # GIVEN an AppSyncEventsResolver with a resolver that conditionally fails
+ app = AppSyncEventsResolver()
+
+ @app.on_publish(path="/default/*")
+ def test_handler(payload):
+ if payload["data"] == "bad data":
+ raise ValueError("Bad data detected")
+ return {"success": True, "data": payload["data"]}
+
+ # WHEN we resolve the event
+ result = app.resolve(mock_event, lambda_context)
+
+ # THEN we should get mixed results with success and error responses
+ assert "events" in result
+ assert len(result["events"]) == 3
+
+ # First event should be successful
+ assert "payload" in result["events"][0]
+ assert result["events"][0]["payload"]["success"] is True
+ assert result["events"][0]["payload"]["data"] == "good data"
+
+ # Second event should have an error
+ assert "error" in result["events"][1]
+ assert "ValueError - Bad data detected" in result["events"][1]["error"]
+
+ # Third event should be successful
+ assert "payload" in result["events"][2]
+ assert result["events"][2]["payload"]["success"] is True
+ assert result["events"][2]["payload"]["data"] == "good data again"
+
+
+def test_router_with_context_sharing(lambda_context, mock_event):
+ """Test that context is properly shared between routers and the main resolver."""
+ # GIVEN a sample publish event
+ mock_event["info"]["channel"]["path"] = "/chat/message"
+ mock_event["events"] = [
+ {"id": "123", "payload": {"data": "test data"}},
+ ]
+
+ # GIVEN a router with context
+ router = Router()
+ router.append_context(service="chat")
+
+ @router.on_publish(path="/chat/*")
+ def router_handler(payload):
+ # Access shared context
+ return {
+ "from_router": True,
+ "service": router.context.get("service"),
+ "tenant": router.context.get("tenant"),
+ }
+
+ # GIVEN an AppSyncEventsResolver with its own context
+ app = AppSyncEventsResolver()
+ app.append_context(tenant="acme")
+
+ # Include the router and merge contexts
+ app.include_router(router)
+
+ # WHEN we resolve the event
+ result = app.resolve(mock_event, lambda_context)
+
+ # THEN the handler should have access to merged context from both sources
+ expected_result = {
+ "events": [
+ {
+ "id": "123",
+ "payload": {
+ "from_router": True,
+ "service": "chat",
+ "tenant": "acme",
+ },
+ },
+ ],
+ }
+ assert result == expected_result
+
+
+def test_context_cleared_after_resolution(lambda_context, mock_event):
+ """Test that context is properly cleared after event resolution."""
+ # GIVEN a sample publish event
+ mock_event["events"] = [
+ {"id": "123", "payload": {"sync_processed": True, "data": "test data"}},
+ ]
+
+ # GIVEN an AppSyncEventsResolver with context data
+ app = AppSyncEventsResolver()
+ app.append_context(request_id="12345")
+
+ @app.on_publish(path="/default/*")
+ def test_handler(payload):
+ # Verify context exists during handler execution
+ assert app.context.get("request_id") == "12345"
+ return {"processed": True}
+
+ # WHEN we resolve the event
+ app.resolve(mock_event, lambda_context)
+
+ # THEN the context should be cleared afterward
+ assert app.context == {}
+
+
+def test_path_matching_mechanism(mocker, lambda_context, mock_event):
+ """Test the path matching mechanism for resolvers."""
+
+ mock_find_resolver = mocker.patch(
+ "aws_lambda_powertools.event_handler.events_appsync._registry.ResolverEventsRegistry.find_resolver",
+ )
+ # GIVEN a resolver that should be found
+ mock_resolver = {
+ "func": lambda payload: {"matched": True},
+ "aggregate": False,
+ }
+ mock_find_resolver.return_value = mock_resolver
+
+ # GIVEN a sample publish event
+ mock_event["info"]["channel"]["path"] = "/chat/room/123/message"
+ mock_event["events"] = [
+ {"id": "123", "payload": {"data": "test data"}},
+ ]
+
+ # GIVEN an AppSyncEventsResolver
+ app = AppSyncEventsResolver()
+
+ # WHEN we resolve the event
+ app.resolve(mock_event, lambda_context)
+
+ # THEN the registry should be queried with the correct path
+ mock_find_resolver.assert_called_with("/chat/room/123/message")
+
+
+def test_async_aggregate_with_parallel_processing(lambda_context, mock_event):
+ """Test that async aggregate handlers can process events in parallel."""
+ # GIVEN a sample publish event with multiple items
+ mock_event["info"]["channel"]["path"] = "/default/process"
+ mock_event["events"] = [
+ {"id": "123", "payload": {"sync_processed": True, "data": "item 1", "delay": 0.03}},
+ {"id": "456", "payload": {"sync_processed": True, "data": "item 2", "delay": 0.02}},
+ {"id": "789", "payload": {"sync_processed": True, "data": "item 3", "delay": 0.01}},
+ ]
+
+ # GIVEN an AppSyncEventsResolver with an async aggregate handler
+ app = AppSyncEventsResolver()
+
+ @app.async_on_publish(path="/default/*", aggregate=True)
+ async def test_async_handler(payload):
+ # Create tasks for each event with different delays
+ tasks = []
+ for idx_event in payload:
+ tasks.append(process_single_event(idx_event["payload"]))
+
+ # Process all events in parallel
+ results = await asyncio.gather(*tasks)
+ return results
+
+ async def process_single_event(payload):
+ # Simulate variable processing time
+ await asyncio.sleep(payload["delay"])
+ return {"processed": True, "data": payload["data"]}
+
+ # WHEN we resolve the event
+ result = app.resolve(mock_event, lambda_context)
+
+ # THEN all events should be processed
+ assert "events" in result
+ assert len(result["events"]) == 3
+
+ # Check all items were processed
+ processed_data = [item["data"] for item in result["events"]]
+ assert "item 1" in processed_data
+ assert "item 2" in processed_data
+ assert "item 3" in processed_data
+
+
+def test_both_app_and_router_for_same_path(lambda_context, mock_event):
+ """Test precedence when both app and router have resolvers for the same path."""
+ # GIVEN a sample publish event
+ mock_event["info"]["channel"]["path"] = "/default/duplicate"
+ mock_event["events"] = [
+ {"id": "123", "payload": {"data": "test data"}},
+ ]
+
+ # GIVEN a router with a resolver
+ router = Router()
+
+ @router.on_publish(path="/default/duplicate")
+ def router_handler(payload):
+ return {"source": "router"}
+
+ # GIVEN an AppSyncEventsResolver with a resolver for the same path
+ app = AppSyncEventsResolver()
+
+ @app.on_publish(path="/default/duplicate")
+ def app_handler(payload):
+ return {"source": "app"}
+
+ # Include the router after defining the app handler
+ app.include_router(router)
+
+ # WHEN we resolve the event
+ result = app.resolve(mock_event, lambda_context)
+
+ # THEN the router's handler should take precedence as it was registered last
+ expected_result = {
+ "events": [
+ {"id": "123", "payload": {"source": "router"}},
+ ],
+ }
+ assert result == expected_result
+
+
+def test_event_with_real_world_example(lambda_context, mock_event):
+ """Test handling a more complex, real-world-like example."""
+ # GIVEN a more realistic publish event with multiple items
+ mock_event["info"]["channel"]["path"] = "/chat/messages"
+ mock_event["events"] = [
+ {
+ "id": "message-123",
+ "payload": {
+ "type": "text",
+ "content": "Hello, world!",
+ "timestamp": 1636718400000,
+ "sender": "user1",
+ },
+ },
+ {
+ "id": "message-456",
+ "payload": {
+ "type": "image",
+ "content": "https://example.com/image.jpg",
+ "timestamp": 1636718500000,
+ "sender": "user2",
+ },
+ },
+ ]
+
+ # GIVEN a router for chat-related operations
+ chat_router = Router()
+
+ @chat_router.on_publish(path="/chat/*")
+ def process_message(payload):
+ # Process message based on type
+ if payload["type"] == "text":
+ return {
+ "processed": True,
+ "messageType": "text",
+ "displayContent": payload["content"],
+ "timestamp": payload["timestamp"],
+ "sender": payload["sender"],
+ }
+ elif payload["type"] == "image":
+ return {
+ "processed": True,
+ "messageType": "image",
+ "displayContent": f"[Image] {payload['content']}",
+ "timestamp": payload["timestamp"],
+ "sender": payload["sender"],
+ }
+ else:
+ return {
+ "processed": False,
+ "error": "Unsupported message type",
+ }
+
+ # GIVEN an AppSyncEventsResolver that includes the router
+ app = AppSyncEventsResolver()
+ app.include_router(chat_router)
+
+ # WHEN we resolve the event
+ result = app.resolve(mock_event, lambda_context)
+
+ # THEN we should get properly processed messages
+ assert "events" in result
+ assert len(result["events"]) == 2
+
+ # Check text message
+ assert result["events"][0]["id"] == "message-123"
+ assert result["events"][0]["payload"]["processed"] is True
+ assert result["events"][0]["payload"]["messageType"] == "text"
+ assert result["events"][0]["payload"]["displayContent"] == "Hello, world!"
+
+ # Check image message
+ assert result["events"][1]["id"] == "message-456"
+ assert result["events"][1]["payload"]["processed"] is True
+ assert result["events"][1]["payload"]["messageType"] == "image"
+ assert result["events"][1]["payload"]["displayContent"] == "[Image] https://example.com/image.jpg"
+
+
+def test_event_response_with_custom_error_handling(lambda_context, mock_event):
+ """Test handling events with custom error handling logic."""
+ # GIVEN a sample publish event
+ mock_event["info"]["channel"]["path"] = "/default/test"
+ mock_event["events"] = [
+ {"id": "123", "payload": {"data": "sensitive data"}},
+ ]
+
+ # GIVEN a custom exception and a router with an async handler
+ class CustomSecurityException(Exception):
+ pass
+
+ router = Router()
+
+ @router.async_on_publish(path="/default/*")
+ async def security_check(payload):
+ # Simulate a security check that blocks certain IDs
+ blocked_data = ["sensitive data"]
+ if payload["data"] in blocked_data:
+ raise CustomSecurityException("Security check failed: Blocked ID")
+
+ await asyncio.sleep(0.01) # Simulate async work
+ return {"security_verified": True, "data": payload["data"]}
+
+ # GIVEN an AppSyncEventsResolver
+ app = AppSyncEventsResolver()
+ app.include_router(router)
+
+ # WHEN we resolve the event
+ result = app.resolve(mock_event, lambda_context)
+
+ # THEN we should get a security error response
+ assert "events" in result
+ assert len(result["events"]) == 1
+ assert "error" in result["events"][0]
+ assert "CustomSecurityException - Security check failed" in result["events"][0]["error"]
+ assert result["events"][0]["id"] == "123"
+
+
+def test_pattern_matching_no_valid_paths(lambda_context, mock_event):
+ """Test that path pattern matching works correctly with wildcards."""
+ # GIVEN a sample publish event
+ mock_event["info"]["channel"]["path"] = "/users/123/notifications/new"
+ mock_event["events"] = [
+ {"id": "123", "payload": {"data": "user notification data"}},
+ ]
+
+ # GIVEN an AppSyncEventsResolver with wildcard path patterns
+ app = AppSyncEventsResolver()
+
+ # Define multiple resolvers with different path patterns
+ @app.on_publish(path="/users/*/notifications/*") # Should not match
+ def user_notification_handler(payload):
+ return {"handler": "wildcard_match", "data": "modified data 1"}
+
+ @app.on_publish(path="/users/123/messages/*") # Should not match
+ def user_message_handler(payload):
+ return {"handler": "wrong_path", "data": "modified data 2"}
+
+ @app.on_publish(path="/*/*/*") # should not match
+ def generic_handler(payload):
+ return {"handler": "generic", "data": "modified data 3"}
+
+ # WHEN we resolve the event
+ result = app.resolve(mock_event, lambda_context)
+
+ # THEN no resolver is found and we return as is
+ expected_result = {
+ "events": [
+ {"id": "123", "payload": {"data": "user notification data"}},
+ ],
+ }
+ assert result == expected_result
+
+
+def test_nested_async_functions(lambda_context, mock_event):
+ """Test that nested async functions work correctly within resolvers."""
+ # GIVEN a sample publish event
+ mock_event["info"]["channel"]["path"] = "/default/nested"
+ mock_event["events"] = [
+ {"id": "123", "payload": {"data": "test data"}},
+ ]
+
+ # GIVEN an AppSyncEventsResolver with a resolver that uses nested async functions
+ app = AppSyncEventsResolver()
+
+ @app.async_on_publish(path="/default/*")
+ async def outer_handler(payload):
+ # Define nested async functions
+ async def validate_data(data):
+ await asyncio.sleep(0.01) # Simulate validation
+ return data.strip() != ""
+
+ async def transform_data(data):
+ await asyncio.sleep(0.01) # Simulate transformation
+ return data.upper()
+
+ # Use nested async functions
+ is_valid = await validate_data(payload["data"])
+ if not is_valid:
+ return {"error": "Invalid data"}
+
+ transformed = await transform_data(payload["data"])
+ return {"validated": is_valid, "transformed": transformed}
+
+ # WHEN we resolve the event
+ result = app.resolve(mock_event, lambda_context)
+
+ # THEN the nested async functions should execute correctly
+ assert "events" in result
+ assert len(result["events"]) == 1
+ assert result["events"][0]["payload"]["validated"] is True
+ assert result["events"][0]["payload"]["transformed"] == "TEST DATA"
+
+
+def test_concurrent_event_processing(lambda_context, mock_event):
+ """Test that multiple events are processed concurrently with async handlers."""
+ # GIVEN a sample publish event with multiple items that take different times to process
+ mock_event["info"]["channel"]["path"] = "/default/concurrent"
+ mock_event["events"] = [
+ {"id": "123", "payload": {"data": "fast data", "delay": 0.01}},
+ {"id": "456", "payload": {"data": "slow data", "delay": 0.03}},
+ {"id": "789", "payload": {"data": "medium data", "delay": 0.02}},
+ ]
+
+ # GIVEN an AppSyncEventsResolver with an async handler
+ app = AppSyncEventsResolver()
+
+ @app.async_on_publish(path="/default/*")
+ async def process_with_variable_delay(payload):
+ # Simulate processing with different delays
+ await asyncio.sleep(payload["delay"])
+ return {
+ "processed": True,
+ "data": payload["data"],
+ "processing_time": payload["delay"],
+ }
+
+ # WHEN we resolve the event
+ import time
+
+ start_time = time.time()
+ result = app.resolve(mock_event, lambda_context)
+ end_time = time.time()
+
+ # THEN all events should be processed
+ assert "events" in result
+ assert len(result["events"]) == 3
+
+ # The total time should be roughly equal to the longest individual delay
+ # (not the sum of all delays, which would indicate sequential processing)
+ processing_time = end_time - start_time
+ assert processing_time < 0.1 # Should be close to the max delay (0.03) plus overhead
+
+ # Check all events were processed
+ ids = [event.get("id") for event in result["events"]]
+ assert set(ids) == {"123", "456", "789"}
+
+
+def test_handler_with_implicit_call_method_in_lambda_function(lambda_context, mock_event):
+ """Test that the __call__ method works correctly as an implicit Lambda handler."""
+ # GIVEN a sample publish event
+ mock_event["events"] = [
+ {"id": "123", "payload": {"data": "test data"}},
+ ]
+
+ # GIVEN an AppSyncEventsResolver
+ app = AppSyncEventsResolver()
+
+ @app.on_publish(path="/default/*")
+ def test_handler(payload):
+ return {"processed": True, "data": payload["data"]}
+
+ # Define a Lambda handler using the app directly
+ def lambda_handler(event, context):
+ return app(event, context) # Using __call__ method
+
+ # WHEN we call the lambda handler
+ result = lambda_handler(mock_event, lambda_context)
+
+ # THEN we should get the expected result
+ expected_result = {
+ "events": [
+ {"id": "123", "payload": {"processed": True, "data": "test data"}},
+ ],
+ }
+ assert result == expected_result
+
+
+def test_middleware_like_functionality(lambda_context, mock_event):
+ """Test implementing middleware-like functionality with context."""
+ # GIVEN a sample publish event
+ mock_event["events"] = [
+ {"id": "123", "payload": {"data": "test data"}},
+ ]
+
+ # GIVEN an AppSyncEventsResolver
+ app = AppSyncEventsResolver()
+
+ # Simulate middleware by adding context before processing
+ def add_request_metadata(event, context, app):
+ app.append_context(
+ request_id="req-123",
+ timestamp=123456789,
+ user_agent="test-agent",
+ )
+
+ # Handler that uses the context added by middleware
+ @app.on_publish(path="/default/*")
+ def handler_with_middleware_data(payload):
+ return {
+ "processed": True,
+ "data": payload["data"],
+ "metadata": {
+ "request_id": app.context.get("request_id"),
+ "timestamp": app.context.get("timestamp"),
+ "user_agent": app.context.get("user_agent"),
+ },
+ }
+
+ # WHEN we add middleware data and resolve the event
+ add_request_metadata(mock_event, lambda_context, app)
+ result = app.resolve(mock_event, lambda_context)
+
+ # THEN the handler should have access to middleware-added context
+ expected_metadata = {
+ "request_id": "req-123",
+ "timestamp": 123456789,
+ "user_agent": "test-agent",
+ }
+
+ assert result["events"][0]["payload"]["metadata"] == expected_metadata
+
+
+def test_handler_with_event_transformation(lambda_context, mock_event):
+ """Test handlers that transform event data before processing."""
+ # GIVEN a sample publish event
+ mock_event["info"]["channel"]["path"] = "/default/transform"
+ mock_event["events"] = [
+ {"id": "123", "payload": {"user_data": {"name": "John", "age": 30}}},
+ {"id": "456", "payload": {"user_data": {"name": "Jane", "age": 16}}},
+ ]
+
+ # GIVEN an AppSyncEventsResolver with a router
+ router = Router()
+
+ # Add middleware context to transform data
+ @router.on_publish(path="/default/*", aggregate=True)
+ def transform_and_process(payload):
+ # Transform the payload structure
+ transformed = []
+ for item in payload:
+ transformed.append(
+ {
+ "id": item["id"],
+ "payload": {
+ "user_data": {
+ "fullName": item["payload"]["user_data"]["name"],
+ "userAge": item["payload"]["user_data"]["age"],
+ "isAdult": item["payload"]["user_data"]["age"] >= 18,
+ },
+ },
+ },
+ )
+ return transformed
+
+ app = AppSyncEventsResolver()
+ app.include_router(router)
+
+ # WHEN we resolve the event
+ result = app.resolve(mock_event, lambda_context)
+
+ # THEN the data should be transformed
+ assert "events" in result
+ assert len(result["events"]) == 2
+
+ # Check transformation results
+ assert result["events"][0]["id"] == "123"
+ assert result["events"][0]["payload"]["user_data"]["fullName"] == "John"
+ assert result["events"][0]["payload"]["user_data"]["userAge"] == 30
+ assert result["events"][0]["payload"]["user_data"]["isAdult"] is True
+
+ assert result["events"][1]["id"] == "456"
+ assert result["events"][1]["payload"]["user_data"]["fullName"] == "Jane"
+ assert result["events"][1]["payload"]["user_data"]["userAge"] == 16
+ assert result["events"][1]["payload"]["user_data"]["isAdult"] is False
+
+
+def test_empty_events_payload(lambda_context, mock_event):
+ """Test handling events with an empty payload."""
+ # GIVEN a sample publish event with empty events
+ mock_event["events"] = []
+
+ # GIVEN an AppSyncEventsResolver
+ app = AppSyncEventsResolver()
+
+ @app.on_publish(path="/default/*", aggregate=True)
+ def handle_events(payload):
+ # Should handle empty payload gracefully
+ if payload == [{}]:
+ return []
+ return [{"processed": True} for _ in payload]
+
+ # WHEN we resolve the event
+ result = app.resolve(mock_event, lambda_context)
+
+ # THEN we should get an empty events list
+ assert "events" in result
+ assert result["events"] == []
+
+
+def test_multiple_related_routes_with_precedence(lambda_context, mock_event):
+ """Test event routing when multiple paths could match an event."""
+ # GIVEN a sample publish event
+ mock_event["info"]["channel"]["path"] = "/products/electronics/phones/123"
+ mock_event["events"] = [
+ {"id": "123", "payload": {"level": "phones", "data": "product data"}},
+ ]
+
+ # GIVEN an AppSyncEventsResolver with multiple related routes
+ app = AppSyncEventsResolver()
+
+ # Define resolvers with varying specificity
+ @app.on_publish(path="/products/*")
+ def general_product_handler(payload):
+ return {"level": "general", "data": payload["data"]}
+
+ @app.on_publish(path="/products/electronics/*")
+ def electronics_handler(payload):
+ return {"level": "electronics", "data": payload["data"]}
+
+ @app.on_publish(path="/products/electronics/phones/*")
+ def phones_handler(payload):
+ return {"level": "phones", "data": payload["data"]}
+
+ # WHEN we resolve the event
+ result = app.resolve(mock_event, lambda_context)
+
+ # THEN the most specific matching path should be used
+ expected_result = {
+ "events": [
+ {"id": "123", "payload": {"level": "phones", "data": "product data"}},
+ ],
+ }
+ assert result == expected_result
+
+
+def test_integration_with_external_service(lambda_context, mock_event):
+ """Test integration with an external service using mocks."""
+ # GIVEN a sample publish event
+ mock_event["info"]["channel"]["path"] = "/orders/process"
+ mock_event["events"] = [
+ {"id": "123", "payload": {"id": "order-123", "product_id": "prod-456", "quantity": 2}},
+ ]
+
+ # Mock an external service
+ class MockOrderService:
+ @staticmethod
+ async def process_order(order_id, product_id, quantity):
+ # Simulate processing delay
+ await asyncio.sleep(0.01)
+ return {
+ "order_id": order_id,
+ "status": "processed",
+ "total_amount": quantity * 10,
+ }
+
+ order_service = MockOrderService()
+
+ # GIVEN an AppSyncEventsResolver with an async resolver using the service
+ app = AppSyncEventsResolver()
+
+ @app.async_on_publish(path="/orders/*")
+ async def process_order(payload):
+ # Call the external service
+ result = await order_service.process_order(
+ order_id=payload["id"],
+ product_id=payload["product_id"],
+ quantity=payload["quantity"],
+ )
+ return {
+ "order_processed": True,
+ "order_details": result,
+ }
+
+ # WHEN we resolve the event
+ result = app.resolve(mock_event, lambda_context)
+
+ # THEN the order should be processed with the external service
+ assert "events" in result
+ assert result["events"][0]["payload"]["order_processed"] is True
+ assert result["events"][0]["payload"]["order_details"]["order_id"] == "order-123"
+ assert result["events"][0]["payload"]["order_details"]["status"] == "processed"
+ assert result["events"][0]["payload"]["order_details"]["total_amount"] == 20 # 2 * 10
+
+
+def test_complex_resolver_hierarchy(lambda_context, mock_event):
+ """Test a complex setup with multiple routers and nested paths."""
+ # GIVEN a complex event
+ mock_event["info"]["channel"]["path"] = "/api/v1/users/profile/update"
+ mock_event["events"] = [
+ {"id": "123", "payload": {"profile": {"name": "John Doe", "email": "john@example.com"}}},
+ ]
+
+ # GIVEN multiple routers for different API parts
+ base_router = Router()
+ users_router = Router()
+ profiles_router = Router()
+
+ # Add handlers to each router
+ @base_router.on_publish(path="/api/*")
+ def api_base_handler(payload):
+ return {"source": "base", "data": payload}
+
+ @users_router.on_publish(path="/api/v1/users/*")
+ def users_handler(payload):
+ return {"source": "users", "data": payload}
+
+ @profiles_router.on_publish(path="/api/v1/users/profile/*")
+ def profile_handler(payload):
+ # Do some profile-specific processing
+ return {
+ "source": "profiles",
+ "updated": True,
+ "profile": {
+ "fullName": payload["profile"]["name"],
+ "email": payload["profile"]["email"],
+ "timestamp": "2023-01-01T00:00:00Z",
+ },
+ }
+
+ # GIVEN an AppSyncEventsResolver with included routers
+ app = AppSyncEventsResolver()
+ app.include_router(base_router)
+ app.include_router(users_router)
+ app.include_router(profiles_router)
+
+ # WHEN we resolve the event
+ result = app.resolve(mock_event, lambda_context)
+
+ # THEN the most specific router's handler should be used
+ assert "events" in result
+ assert result["events"][0]["id"] == "123"
+ assert result["events"][0]["payload"]["source"] == "profiles"
+ assert result["events"][0]["payload"]["updated"] is True
+ assert "fullName" in result["events"][0]["payload"]["profile"]
+ assert result["events"][0]["payload"]["profile"]["fullName"] == "John Doe"
+
+
+def test_warning_behavior_with_no_matching_resolver(lambda_context, mock_event):
+ """Test warning behavior when no matching resolver is found."""
+ # GIVEN a sample publish event with an unmatched path
+ mock_event["info"]["channel"]["path"] = "/unmatched/path"
+ mock_event["events"] = [
+ {"id": "123", "payload": {"data": "test data"}},
+ ]
+
+ # GIVEN an AppSyncEventsResolver with a resolver for a different path
+ app = AppSyncEventsResolver()
+
+ @app.on_publish(path="/matched/path")
+ def test_handler(payload):
+ return {"processed": True}
+
+ # WHEN we resolve the event
+ # THEN a warning should be generated
+ with pytest.warns(UserWarning, match="No resolvers were found for publish operations with path /unmatched/path"):
+ result = app.resolve(mock_event, lambda_context)
+
+ # AND the payload should be returned as is
+ assert result == {"events": [{"id": "123", "payload": {"data": "test data"}}]}
+
+
+def test_resolver_precedence_with_exact_match(lambda_context, mock_event):
+ """Test that exact path matches have precedence over wildcard matches."""
+ # GIVEN a sample publish event
+ mock_event["info"]["channel"]["path"] = "/notifications/system"
+ mock_event["events"] = [
+ {"id": "123", "payload": {"message": "System notification"}},
+ ]
+
+ # GIVEN an AppSyncEventsResolver with both wildcard and exact path resolvers
+ app = AppSyncEventsResolver()
+
+ @app.on_publish(path="/notifications/*")
+ def wildcard_handler(payload):
+ return {"source": "wildcard", "message": payload["message"]}
+
+ @app.on_publish(path="/notifications/system")
+ def exact_handler(payload):
+ return {"source": "exact", "message": payload["message"]}
+
+ # WHEN we resolve the event
+ result = app.resolve(mock_event, lambda_context)
+
+ # THEN the exact path match should take precedence
+ expected_result = {
+ "events": [
+ {"id": "123", "payload": {"source": "exact", "message": "System notification"}},
+ ],
+ }
+ assert result == expected_result
+
+
+def test_custom_routing_patterns(lambda_context, mock_event):
+ """Test custom routing patterns beyond simple wildcards."""
+ # GIVEN events with different path formats
+ event1 = deepcopy(mock_event)
+ event2 = deepcopy(mock_event)
+
+ event1["info"]["channel"]["path"] = "/users/123/posts/456"
+ event1["events"] = [
+ {"id": "123", "payload": {"data": "user post data"}},
+ ]
+
+ event2["info"]["channel"]["path"] = "/organizations/abc/members/xyz"
+ event2["events"] = [
+ {"id": "123", "payload": {"data": "organization member data"}},
+ ]
+
+ # GIVEN an AppSyncEventsResolver with pattern-based routing
+ app = AppSyncEventsResolver()
+
+ # Define resolvers for different entity patterns
+ @app.on_publish(path="/users/*")
+ def user_resource_handler(payload):
+ path = app.current_event.info.channel_path
+ segments = path.split("/")
+ user_id = segments[2]
+ resource_type = segments[3]
+
+ return {"entity_type": "user", "entity_id": user_id, "resource_type": resource_type, "data": payload["data"]}
+
+ @app.on_publish(path="/organizations/*")
+ def org_resource_handler(payload):
+ path = app.current_event.info.channel_path
+ segments = path.split("/")
+ org_id = segments[2]
+ resource_type = segments[3]
+
+ return {
+ "entity_type": "organization",
+ "entity_id": org_id,
+ "resource_type": resource_type,
+ "data": payload["data"],
+ }
+
+ # WHEN we resolve the events
+ result1 = app.resolve(event1, lambda_context)
+ result2 = app.resolve(event2, lambda_context)
+
+ # THEN each event should be handled by the appropriate pattern-based resolver
+ assert result1["events"][0]["payload"]["entity_type"] == "user"
+ assert result1["events"][0]["payload"]["entity_id"] == "123"
+ assert result1["events"][0]["payload"]["resource_type"] == "posts"
+
+ assert result2["events"][0]["payload"]["entity_type"] == "organization"
+ assert result2["events"][0]["payload"]["entity_id"] == "abc"
+ assert result2["events"][0]["payload"]["resource_type"] == "members"
+
+
+def test_warning_on_invalid_response_format(lambda_context, mock_event):
+ """Test warning generation for invalid response formats."""
+ # GIVEN a sample publish event
+ mock_event["info"]["channel"]["path"] = "/default/test"
+ mock_event["events"] = [
+ {"id": "123", "payload": {"data": "test data"}},
+ {"id": "456", "payload": {"data": "more data"}},
+ ]
+
+ # GIVEN an AppSyncEventsResolver with an aggregate handler that returns non-list
+ app = AppSyncEventsResolver()
+
+ @app.on_publish(path="/default/*", aggregate=True)
+ def invalid_format_handler(payload):
+ # Incorrectly return a dict instead of a list
+ return {"processed": True, "count": len(payload)}
+
+ # WHEN we resolve the event
+ # THEN a warning should be generated about the response format
+ with pytest.warns(UserWarning, match="Response must be a list when using aggregate"):
+ result = app.resolve(mock_event, lambda_context)
+
+ # The result should still contain what was returned
+ assert "events" in result
+ assert result["events"]["processed"] is True
+ assert result["events"]["count"] == 2
+
+
+def test_router_and_resolver_clear_context_after_resolution(lambda_context, mock_event):
+ """Test that both router and resolver's context are cleared after resolution."""
+ # GIVEN a sample publish event
+ mock_event["events"] = [
+ {"id": "123", "payload": {"data": "test data"}},
+ ]
+
+ # GIVEN a router with context data
+ router = Router()
+ router.append_context(router_key="router_value")
+
+ @router.on_publish(path="/default/*")
+ def router_handler(payload):
+ assert router.context["router_key"] == "router_value"
+ assert router.context["test_var"] == "app_value"
+ return {"processed": True}
+
+ # GIVEN an AppSyncEventsResolver with context data
+ app = AppSyncEventsResolver()
+ app.append_context(test_var="app_value")
+
+ # Include the router and merge contexts
+ app.include_router(router)
+
+ # WHEN we resolve the event
+ app.resolve(mock_event, lambda_context)
+
+ # THEN both contexts should be cleared
+ assert app.context == {}
+ assert router.context == {}
+
+
+def test_sync_and_async_router_inclusion(lambda_context, mock_event):
+ """Test including multiple routers with both sync and async handlers."""
+ # GIVEN a sample publish event
+ mock_event["info"]["channel"]["path"] = "/notifications/test"
+ mock_event["events"] = [
+ {"id": "123", "payload": {"message": "test notification"}},
+ ]
+
+ # GIVEN a router with synchronous handlers
+ sync_router = Router()
+
+ @sync_router.on_publish(path="/notifications/*")
+ def sync_handler(payload):
+ return {"sync": True, "message": payload["message"]}
+
+ # GIVEN another router with asynchronous handlers
+ async_router = Router()
+
+ @async_router.async_on_publish(path="/notifications/*")
+ async def async_handler(event):
+ await asyncio.sleep(0.01)
+ return {"async": True, "message": event["message"]}
+
+ # GIVEN an AppSyncEventsResolver that includes both routers
+ app = AppSyncEventsResolver()
+ app.include_router(sync_router)
+ app.include_router(async_router)
+
+ # WHEN we resolve the event
+ with pytest.warns(UserWarning, match="Both synchronous and asynchronous resolvers found"):
+ result = app.resolve(mock_event, lambda_context)
+
+ # THEN the sync handler should take precedence
+ expected_result = {
+ "events": [
+ {"id": "123", "payload": {"sync": True, "message": "test notification"}},
+ ],
+ }
+ assert result == expected_result
+
+
+def test_aws_lambda_context_availability_in_handlers(lambda_context, mock_event):
+ """Test that Lambda context is available in handlers."""
+ # GIVEN a sample publish event
+ mock_event["info"]["channel"]["path"] = "/default/test"
+ mock_event["events"] = [
+ {"id": "123", "payload": {"data": "test data"}},
+ ]
+
+ # GIVEN an AppSyncEventsResolver with a handler that uses Lambda context
+ app = AppSyncEventsResolver()
+
+ @app.on_publish(path="/default/*")
+ def context_aware_handler(payload):
+ # Access Lambda context information
+ return {
+ "processed": True,
+ "function_name": app.lambda_context.function_name,
+ "request_id": app.lambda_context.aws_request_id,
+ "function_arn": app.lambda_context.invoked_function_arn,
+ "payload_data": payload["data"],
+ }
+
+ # WHEN we resolve the event
+ result = app.resolve(mock_event, lambda_context)
+
+ # THEN Lambda context information should be included in the result
+ assert result["events"][0]["payload"]["function_name"] == lambda_context.function_name
+ assert result["events"][0]["payload"]["request_id"] == lambda_context.aws_request_id
+ assert result["events"][0]["payload"]["function_arn"] == lambda_context.invoked_function_arn
+ assert result["events"][0]["payload"]["payload_data"] == "test data"
+
+
+def test_router_lambda_context_shared(lambda_context, mock_event):
+ """Test that Lambda context is shared with included routers."""
+ # GIVEN a sample publish event
+ mock_event["info"]["channel"]["path"] = "/router/test"
+ mock_event["events"] = [
+ {"id": "123", "payload": {"data": "test data"}},
+ ]
+
+ # GIVEN a router with a handler that uses Lambda context
+ router = Router()
+
+ @router.on_publish(path="/router/*")
+ def router_context_handler(payload):
+ # Access Lambda context from the router
+ return {
+ "from_router": True,
+ "function_name": router.lambda_context.function_name,
+ "request_id": router.lambda_context.aws_request_id,
+ "payload_data": payload["data"],
+ }
+
+ # GIVEN an AppSyncEventsResolver that includes the router
+ app = AppSyncEventsResolver()
+ app.include_router(router)
+
+ # WHEN we resolve the event
+ result = app.resolve(mock_event, lambda_context)
+
+ # THEN the router should have access to the same Lambda context
+ assert result["events"][0]["payload"]["from_router"] is True
+ assert result["events"][0]["payload"]["function_name"] == lambda_context.function_name
+ assert result["events"][0]["payload"]["request_id"] == lambda_context.aws_request_id
+ assert result["events"][0]["payload"]["payload_data"] == "test data"
+
+
+def test_current_event_availability(lambda_context, mock_event):
+ """Test that current_event is properly available to handlers."""
+ # GIVEN a sample publish event with extra metadata
+ mock_event["info"]["channel"]["path"] = "/default/test"
+ mock_event["events"] = [
+ {"id": "123", "payload": {"data": "test data"}},
+ ]
+
+ # GIVEN an AppSyncEventsResolver with a handler that accesses current_event
+ app = AppSyncEventsResolver()
+
+ @app.on_publish(path="/default/*")
+ def event_aware_handler(payload):
+ # Access the full event object for additional context
+ return {
+ "processed": True,
+ "x-forwarded-for": app.current_event.request_headers["x-forwarded-for"],
+ "payload_data": payload["data"],
+ }
+
+ # WHEN we resolve the event
+ result = app.resolve(mock_event, lambda_context)
+
+ # THEN the handler should have access to the full event information
+ assert result["events"][0]["payload"]["processed"] is True
+ assert result["events"][0]["payload"]["x-forwarded-for"] == mock_event["request"]["headers"]["x-forwarded-for"]
+ assert result["events"][0]["payload"]["payload_data"] == "test data"
+
+
+def test_router_current_event_shared(lambda_context, mock_event):
+ """Test that current_event is shared with included routers."""
+ # GIVEN a sample publish event with extra metadata
+ mock_event["info"]["channel"]["path"] = "/router/test"
+ mock_event["events"] = [
+ {"id": "123", "payload": {"data": "test data"}},
+ ]
+
+ # GIVEN a router with a handler that accesses current_event
+ router = Router()
+
+ @router.on_publish(path="/router/*")
+ def router_event_handler(payload):
+ # Access event information from the router
+ return {
+ "processed": True,
+ "x-forwarded-for": app.current_event.request_headers["x-forwarded-for"],
+ "payload_data": payload["data"],
+ }
+
+ # GIVEN an AppSyncEventsResolver that includes the router
+ app = AppSyncEventsResolver()
+ app.include_router(router)
+
+ # WHEN we resolve the event
+ result = app.resolve(mock_event, lambda_context)
+
+ # THEN the router should have access to the same event information
+ assert result["events"][0]["payload"]["processed"] is True
+ assert result["events"][0]["payload"]["x-forwarded-for"] == mock_event["request"]["headers"]["x-forwarded-for"]
+ assert result["events"][0]["payload"]["payload_data"] == "test data"
+
+
+@pytest.mark.skip(reason="Not implemented yet")
+def test_channel_path_normalization(lambda_context, mock_event):
+ """Test that channel paths are properly normalized before matching."""
+ # GIVEN sample publish events with different path formats
+ event1 = deepcopy(mock_event)
+ event2 = deepcopy(mock_event)
+
+ event1["info"]["channel"]["path"] = "/test"
+ event1["events"] = [
+ {"id": "123", "payload": {"data": "data1"}},
+ ]
+
+ event2["info"]["channel"]["path"] = "/test/"
+ event2["events"] = [
+ {"id": "456", "payload": {"data": "data2"}},
+ ]
+
+ # GIVEN an AppSyncEventsResolver with a handler
+ app = AppSyncEventsResolver()
+
+ @app.on_publish(path="/test") # Register with path without trailing slash
+ def test_handler(payload):
+ return {"normalized": True, "data": payload["data"]}
+
+ # WHEN we resolve both events
+ result1 = app.resolve(event1, lambda_context)
+ result2 = app.resolve(event2, lambda_context)
+
+ # THEN both events should be handled consistently
+ expected_result1 = {
+ "events": [
+ {"id": "123", "payload": {"normalized": True, "data": "data1"}},
+ ],
+ }
+ assert result1 == expected_result1
+
+ # With proper normalization, this should also match
+ expected_result2 = {
+ "events": [
+ {"id": "456", "payload": {"normalized": True, "data": "data2"}},
+ ],
+ }
+ assert result2 == expected_result2
+
+
+def test_subscribe_event_with_error_handling(lambda_context, mock_event):
+ """Test error handling during publish event processing."""
+ # GIVEN a sample publish event
+ mock_event["info"]["operation"] = "SUBSCRIBE"
+ mock_event["info"]["channel"]["path"] = "/default/powertools"
+ del mock_event["events"] # SUBSCRIBE events are not supported
+
+ # GIVEN an AppSyncEventsResolver with a resolver that raises an exception
+ app = AppSyncEventsResolver()
+
+ @app.on_subscribe(path="/default/*")
+ def test_handler():
+ raise ValueError("Test error")
+
+ # WHEN we resolve the event
+ result = app.resolve(mock_event, lambda_context)
+
+ # THEN we should get an error response
+ assert "error" in result
+ assert "ValueError - Test error" in result["error"]
+
+
+def test_subscribe_event_with_valid_return(lambda_context, mock_event):
+ """Test error handling during publish event processing."""
+ # GIVEN a sample publish event
+ mock_event["info"]["operation"] = "SUBSCRIBE"
+ mock_event["info"]["channel"]["path"] = "/default/powertools"
+
+ # GIVEN an AppSyncEventsResolver with a resolver that returns ok
+ app = AppSyncEventsResolver()
+
+ @app.on_subscribe(path="/default/*")
+ def test_handler():
+ return 1
+
+ # WHEN we resolve the event
+ result = app.resolve(mock_event, lambda_context)
+
+ # THEN we should return None because subscribe always must return None
+ assert result is None
+
+
+def test_subscribe_event_with_no_resolver(lambda_context, mock_event):
+ """Test error handling during publish event processing."""
+ # GIVEN a sample publish event
+ mock_event["info"]["operation"] = "SUBSCRIBE"
+ mock_event["info"]["channel"]["path"] = "/default/powertools"
+
+ # GIVEN an AppSyncEventsResolver with a resolver that returns ok
+ app = AppSyncEventsResolver()
+
+ @app.on_subscribe(path="/test")
+ def test_handler():
+ return 1
+
+ # WHEN we resolve the event
+ result = app.resolve(mock_event, lambda_context)
+
+ # THEN we should get an error response
+ assert not result
+
+
+def test_publish_events_throw_unauthorized_exception(lambda_context, mock_event):
+ """Test handling events with an empty payload."""
+ # GIVEN a sample publish event with empty events
+ mock_event["info"]["operation"] = "PUBLISH"
+ mock_event["info"]["channel"]["path"] = "/default/test"
+ mock_event["events"] = [
+ {"id": "123", "payload": {"data": "test data"}},
+ ]
+
+ # GIVEN an AppSyncEventsResolver
+ app = AppSyncEventsResolver()
+
+ @app.on_publish(path="/default/*", aggregate=True)
+ def handle_events(payload):
+ raise UnauthorizedException
+
+ # WHEN we resolve the event with unauthorized route
+ with pytest.raises(UnauthorizedException):
+ app.resolve(mock_event, lambda_context)
+
+
+def test_subscribe_events_throw_unauthorized_exception(lambda_context, mock_event):
+ """Test handling events with an empty payload."""
+ # GIVEN a sample publish event with empty events
+ mock_event["info"]["operation"] = "SUBSCRIBE"
+ mock_event["info"]["channel"]["path"] = "/default/test"
+
+ # GIVEN an AppSyncEventsResolver
+ app = AppSyncEventsResolver()
+
+ @app.on_subscribe(path="/default/*")
+ def handle_events():
+ raise UnauthorizedException
+
+ # WHEN we resolve the event with unauthorized route
+ with pytest.raises(UnauthorizedException):
+ app.resolve(mock_event, lambda_context)
diff --git a/tests/functional/event_handler/test_appsync.py b/tests/functional/event_handler/required_dependencies/appsync/test_appsync_single_resolvers.py
similarity index 60%
rename from tests/functional/event_handler/test_appsync.py
rename to tests/functional/event_handler/required_dependencies/appsync/test_appsync_single_resolvers.py
index 54695eba240..4ef902c340a 100644
--- a/tests/functional/event_handler/test_appsync.py
+++ b/tests/functional/event_handler/required_dependencies/appsync/test_appsync_single_resolvers.py
@@ -1,10 +1,11 @@
+from __future__ import annotations
+
import asyncio
-import sys
import pytest
from aws_lambda_powertools.event_handler import AppSyncResolver
-from aws_lambda_powertools.event_handler.appsync import Router
+from aws_lambda_powertools.event_handler.graphql_appsync.router import Router
from aws_lambda_powertools.utilities.data_classes import AppSyncResolverEvent
from aws_lambda_powertools.utilities.typing import LambdaContext
from tests.functional.utils import load_event
@@ -27,6 +28,40 @@ def create_something(id: str): # noqa AA03 VNE003
assert result == "my identifier"
+def test_direct_resolver_with_parent_name():
+ # Check whether we can handle an example appsync direct resolver
+ mock_event = load_event("appSyncDirectResolver.json")
+
+ app = AppSyncResolver()
+
+ @app.resolver(field_name="createSomething", type_name="Mutation")
+ def create_something(id: str): # noqa AA03 VNE003
+ assert app.lambda_context == {}
+ return id
+
+ # Call the implicit handler
+ result = app(mock_event, {})
+
+ assert result == "my identifier"
+
+
+def test_custom_resolver_with_fields():
+ # Check whether we can handle an example appsync with custom resolver
+ mock_event = load_event("appSyncCustomResolverEvent.json")
+
+ app = AppSyncResolver()
+
+ @app.resolver(field_name="locations", type_name="Merchant")
+ def create_something(page: int): # noqa AA03 VNE003
+ assert app.lambda_context == {}
+ return page
+
+ # Call the implicit handler
+ result = app(mock_event, {})
+
+ assert result == 2
+
+
def test_amplify_resolver():
# Check whether we can handle an example appsync resolver
mock_event = load_event("appSyncResolverEvent.json")
@@ -121,7 +156,6 @@ def get_locations(name: str, description: str = ""):
assert result2 == "value2description"
-@pytest.mark.skipif(sys.version_info < (3, 8), reason="only for python versions that support asyncio.run")
def test_resolver_async():
# GIVEN
app = AppSyncResolver()
@@ -147,8 +181,8 @@ def test_resolve_custom_data_model():
class MyCustomModel(AppSyncResolverEvent):
@property
- def country_viewer(self):
- return self.request_headers.get("cloudfront-viewer-country")
+ def country_viewer(self) -> str:
+ return self.request_headers.get("cloudfront-viewer-country", "")
app = AppSyncResolver()
@@ -171,11 +205,11 @@ def test_resolver_include_resolver():
@router.resolver(type_name="Query", field_name="listLocations")
def get_locations(name: str):
- return "get_locations#" + name
+ return f"get_locations#{name}"
@app.resolver(field_name="listLocations2")
def get_locations2(name: str):
- return "get_locations2#" + name
+ return f"get_locations2#{name}"
app.include_router(router)
@@ -227,7 +261,7 @@ def test_router_has_access_to_app_context():
@router.resolver(type_name="Query", field_name="listLocations")
def get_locations(name: str):
- if router.context["is_admin"]:
+ if router.context.get("is_admin"):
return f"get_locations#{name}"
app.include_router(router)
@@ -253,3 +287,103 @@ def test_include_router_merges_context():
app.include_router(router)
assert app.context == router.context
+
+
+def test_include_router_access_current_event():
+ mock_event = load_event("appSyncDirectResolver.json")
+
+ # GIVEN An instance of AppSyncResolver, a Router instance, and a resolver function registered with the router
+ app = AppSyncResolver()
+ router = Router()
+
+ @router.resolver(field_name="createSomething")
+ def get_user(id: str) -> dict: # noqa AA03 VNE003
+ return router.current_event.identity.sub
+
+ app.include_router(router)
+
+ # WHEN we resolve the event
+ ret = app.resolve(mock_event, {})
+
+ # THEN the resolver must be able to return a field in the current_event
+ assert ret == mock_event["identity"]["sub"]
+
+
+def test_app_access_current_event():
+ # Check whether we can handle an example appsync direct resolver
+ mock_event = load_event("appSyncDirectResolver.json")
+
+ # GIVEN An instance of AppSyncResolver and a resolver function registered with the app
+ app = AppSyncResolver()
+
+ @app.resolver(field_name="createSomething")
+ def get_user(id: str) -> dict: # noqa AA03 VNE003
+ return app.current_event.identity.sub
+
+ # WHEN we resolve the event
+ ret = app.resolve(mock_event, {})
+
+ # THEN the resolver must be able to return a field in the current_event
+ assert ret == mock_event["identity"]["sub"]
+
+
+def test_route_context_is_not_cleared_after_resolve_async():
+ # GIVEN
+ app = AppSyncResolver()
+ event = {"typeName": "Query", "fieldName": "listLocations", "arguments": {"name": "value"}}
+
+ @app.resolver(field_name="listLocations")
+ async def get_locations(name: str):
+ return f"get_locations#{name}"
+
+ # WHEN event resolution kicks in
+ app.append_context(is_admin=True)
+ app.resolve(event, {})
+
+ # THEN context should be empty
+ assert app.context == {"is_admin": True}
+
+
+def test_route_context_is_manually_cleared_after_resolve_async():
+ # GIVEN
+ # GIVEN
+ app = AppSyncResolver()
+
+ mock_event = {"typeName": "Customer", "fieldName": "field", "arguments": {}}
+
+ @app.resolver(field_name="field")
+ async def get_async():
+ app.context.clear()
+ await asyncio.sleep(0.0001)
+ return "value"
+
+ # WHEN
+ mock_context = LambdaContext()
+ app.append_context(is_admin=True)
+ result = app.resolve(mock_event, mock_context)
+
+ # THEN
+ assert asyncio.run(result) == "value"
+ assert app.context == {}
+
+
+def test_exception_handler_with_single_resolver():
+ # GIVEN a AppSyncResolver instance
+ mock_event = load_event("appSyncDirectResolver.json")
+
+ app = AppSyncResolver()
+
+ # WHEN we configure exception handler for ValueError
+ @app.exception_handler(ValueError)
+ def handle_value_error(ex: ValueError):
+ return {"message": "error"}
+
+ @app.resolver(field_name="createSomething")
+ def create_something(id: str): # noqa AA03 VNE003
+ raise ValueError("Error")
+
+ # Call the implicit handler
+ result = app(mock_event, {})
+
+ # THEN the return must be the Exception Handler error message
+ assert result["message"] == "error"
diff --git a/tests/functional/event_handler/required_dependencies/conftest.py b/tests/functional/event_handler/required_dependencies/conftest.py
new file mode 100644
index 00000000000..5c2bdb7729a
--- /dev/null
+++ b/tests/functional/event_handler/required_dependencies/conftest.py
@@ -0,0 +1,73 @@
+import json
+
+import pytest
+
+from tests.functional.utils import load_event
+
+
+@pytest.fixture
+def json_dump():
+ # our serializers reduce length to save on costs; fixture to replicate separators
+ return lambda obj: json.dumps(obj, separators=(",", ":"))
+
+
+@pytest.fixture
+def validation_schema():
+ return {
+ "$schema": "https://json-schema.org/draft-07/schema",
+ "$id": "https://example.com/example.json",
+ "type": "object",
+ "title": "Sample schema",
+ "description": "The root schema comprises the entire JSON document.",
+ "examples": [{"message": "hello world", "username": "lessa"}],
+ "required": ["message", "username"],
+ "properties": {
+ "message": {
+ "$id": "#/properties/message",
+ "type": "string",
+ "title": "The message",
+ "examples": ["hello world"],
+ },
+ "username": {
+ "$id": "#/properties/username",
+ "type": "string",
+ "title": "The username",
+ "examples": ["lessa"],
+ },
+ },
+ }
+
+
+@pytest.fixture
+def raw_event():
+ return {"message": "hello hello", "username": "blah blah"}
+
+
+@pytest.fixture
+def gw_event():
+ return load_event("apiGatewayProxyEvent.json")
+
+
+@pytest.fixture
+def gw_event_http():
+ return load_event("apiGatewayProxyV2Event.json")
+
+
+@pytest.fixture
+def gw_event_alb():
+ return load_event("albMultiValueQueryStringEvent.json")
+
+
+@pytest.fixture
+def gw_event_lambda_url():
+ return load_event("lambdaFunctionUrlEventWithHeaders.json")
+
+
+@pytest.fixture
+def gw_event_vpc_lattice():
+ return load_event("vpcLatticeV2EventWithHeaders.json")
+
+
+@pytest.fixture
+def gw_event_vpc_lattice_v1():
+ return load_event("vpcLatticeEvent.json")
diff --git a/tests/functional/event_handler/test_api_gateway.py b/tests/functional/event_handler/required_dependencies/test_api_gateway.py
similarity index 55%
rename from tests/functional/event_handler/test_api_gateway.py
rename to tests/functional/event_handler/required_dependencies/test_api_gateway.py
index ae2c3eee43e..e5ed7b7cb78 100644
--- a/tests/functional/event_handler/test_api_gateway.py
+++ b/tests/functional/event_handler/required_dependencies/test_api_gateway.py
@@ -1,16 +1,22 @@
+from __future__ import annotations
+
import base64
import json
+import re
import zlib
+from collections import deque
from copy import deepcopy
from decimal import Decimal
from enum import Enum
+from functools import partial
from json import JSONEncoder
from pathlib import Path
-from typing import Dict
import pytest
-from aws_lambda_powertools.event_handler import content_types
+from aws_lambda_powertools.event_handler import (
+ content_types,
+)
from aws_lambda_powertools.event_handler.api_gateway import (
ALBResolver,
APIGatewayHttpResolver,
@@ -24,12 +30,17 @@
)
from aws_lambda_powertools.event_handler.exceptions import (
BadRequestError,
+ ForbiddenError,
InternalServerError,
NotFoundError,
+ RequestEntityTooLargeError,
+ RequestTimeoutError,
ServiceError,
+ ServiceUnavailableError,
UnauthorizedError,
)
from aws_lambda_powertools.shared import constants
+from aws_lambda_powertools.shared.cookies import Cookie
from aws_lambda_powertools.shared.json_encoder import Encoder
from aws_lambda_powertools.utilities.data_classes import (
ALBEvent,
@@ -40,18 +51,14 @@
from tests.functional.utils import load_event
-@pytest.fixture
-def json_dump():
- # our serializers reduce length to save on costs; fixture to replicate separators
- return lambda obj: json.dumps(obj, separators=(",", ":"))
-
-
def read_media(file_name: str) -> bytes:
- path = Path(str(Path(__file__).parent.parent.parent.parent) + "/docs/media/" + file_name)
+ path = Path(f"{str(Path(__file__).parent.parent.parent.parent)}/../docs/media/{file_name}")
return path.read_bytes()
LOAD_GW_EVENT = load_event("apiGatewayProxyEvent.json")
+LOAD_GW_EVENT_NO_ORIGIN = load_event("apiGatewayProxyEventNoOrigin.json")
+LOAD_GW_EVENT_TRAILING_SLASH = load_event("apiGatewayProxyEventPathTrailingSlash.json")
def test_alb_event():
@@ -75,6 +82,27 @@ def foo():
assert result["body"] == "foo"
+def test_alb_event_path_trailing_slash(json_dump):
+ # GIVEN an Application Load Balancer proxy type event
+ app = ALBResolver()
+
+ @app.get("/lambda")
+ def foo():
+ assert isinstance(app.current_event, ALBEvent)
+ assert app.lambda_context == {}
+ assert app.current_event.request_context.elb_target_group_arn is not None
+ return Response(200, content_types.TEXT_HTML, "foo")
+
+ # WHEN calling the event handler using path with trailing "/"
+ result = app(load_event("albEventPathTrailingSlash.json"), {})
+
+ # THEN
+ assert result["statusCode"] == 404
+ assert result["headers"]["Content-Type"] == content_types.APPLICATION_JSON
+ expected = {"statusCode": 404, "message": "Not found"}
+ assert result["body"] == json_dump(expected)
+
+
def test_api_gateway_v1():
# GIVEN a Http API V1 proxy type event
app = APIGatewayRestResolver()
@@ -92,7 +120,43 @@ def get_lambda() -> Response:
# THEN process event correctly
# AND set the current_event type as APIGatewayProxyEvent
assert result["statusCode"] == 200
- assert result["headers"]["Content-Type"] == content_types.APPLICATION_JSON
+ assert result["multiValueHeaders"]["Content-Type"] == [content_types.APPLICATION_JSON]
+
+
+def test_api_gateway_v1_path_trailing_slash():
+ # GIVEN a Http API V1 proxy type event
+ app = APIGatewayRestResolver()
+
+ @app.get("/my/path")
+ def get_lambda() -> Response:
+ return Response(200, content_types.APPLICATION_JSON, json.dumps({"foo": "value"}))
+
+ # WHEN calling the event handler
+ result = app(LOAD_GW_EVENT_TRAILING_SLASH, {})
+
+ # THEN process event correctly
+ # AND set the current_event type as APIGatewayProxyEvent
+ assert result["statusCode"] == 200
+ assert result["multiValueHeaders"]["Content-Type"] == [content_types.APPLICATION_JSON]
+
+
+def test_api_gateway_v1_cookies():
+ # GIVEN a Http API V1 proxy type event
+ app = APIGatewayRestResolver()
+ cookie = Cookie(name="CookieMonster", value="MonsterCookie")
+
+ @app.get("/my/path")
+ def get_lambda() -> Response:
+ assert isinstance(app.current_event, APIGatewayProxyEvent)
+ return Response(200, content_types.TEXT_PLAIN, "Hello world", cookies=[cookie])
+
+ # WHEN calling the event handler
+ result = app(LOAD_GW_EVENT, {})
+
+ # THEN process event correctly
+ # AND set the current_event type as APIGatewayProxyEvent
+ assert result["statusCode"] == 200
+ assert result["multiValueHeaders"]["Set-Cookie"] == ["CookieMonster=MonsterCookie; Secure"]
def test_api_gateway():
@@ -110,10 +174,28 @@ def get_lambda() -> Response:
# THEN process event correctly
# AND set the current_event type as APIGatewayProxyEvent
assert result["statusCode"] == 200
- assert result["headers"]["Content-Type"] == content_types.TEXT_HTML
+ assert result["multiValueHeaders"]["Content-Type"] == [content_types.TEXT_HTML]
assert result["body"] == "foo"
+def test_api_gateway_event_path_trailing_slash(json_dump):
+ # GIVEN a Rest API Gateway proxy type event
+ app = ApiGatewayResolver(proxy_type=ProxyEventType.APIGatewayProxyEvent)
+
+ @app.get("/my/path")
+ def get_lambda() -> Response:
+ assert isinstance(app.current_event, APIGatewayProxyEvent)
+ return Response(200, content_types.TEXT_HTML, "foo")
+
+ # WHEN calling the event handler
+ result = app(LOAD_GW_EVENT_TRAILING_SLASH, {})
+ # THEN
+ assert result["statusCode"] == 404
+ assert result["multiValueHeaders"]["Content-Type"] == [content_types.APPLICATION_JSON]
+ expected = {"statusCode": 404, "message": "Not found"}
+ assert result["body"] == json_dump(expected)
+
+
def test_api_gateway_v2():
# GIVEN a Http API V2 proxy type event
app = APIGatewayHttpResolver()
@@ -132,9 +214,49 @@ def my_path() -> Response:
# AND set the current_event type as APIGatewayProxyEventV2
assert result["statusCode"] == 200
assert result["headers"]["Content-Type"] == content_types.TEXT_PLAIN
+ assert "Cookies" not in result["headers"]
assert result["body"] == "tom"
+def test_api_gateway_v2_http_path_trailing_slash(json_dump):
+ # GIVEN a Http API V2 proxy type event
+ app = APIGatewayHttpResolver()
+
+ @app.post("/my/path")
+ def my_path() -> Response:
+ post_data = app.current_event.json_body
+ return Response(200, content_types.TEXT_PLAIN, post_data["username"])
+
+ # WHEN calling the event handler
+ result = app(load_event("apiGatewayProxyV2EventPathTrailingSlash.json"), {})
+
+ # THEN expect a 404 response
+ assert result["statusCode"] == 404
+ assert result["headers"]["Content-Type"] == content_types.APPLICATION_JSON
+ expected = {"statusCode": 404, "message": "Not found"}
+ assert result["body"] == json_dump(expected)
+
+
+def test_api_gateway_v2_cookies():
+ # GIVEN a Http API V2 proxy type event
+ app = APIGatewayHttpResolver()
+ cookie = Cookie(name="CookieMonster", value="MonsterCookie")
+
+ @app.post("/my/path")
+ def my_path() -> Response:
+ assert isinstance(app.current_event, APIGatewayProxyEventV2)
+ return Response(200, content_types.TEXT_PLAIN, "Hello world", cookies=[cookie])
+
+ # WHEN calling the event handler
+ result = app(load_event("apiGatewayProxyV2Event.json"), {})
+
+ # THEN process event correctly
+ # AND set the current_event type as APIGatewayProxyEventV2
+ assert result["statusCode"] == 200
+ assert result["headers"]["Content-Type"] == content_types.TEXT_PLAIN
+ assert result["cookies"] == ["CookieMonster=MonsterCookie; Secure"]
+
+
def test_include_rule_matching():
# GIVEN
app = ApiGatewayResolver()
@@ -149,7 +271,7 @@ def get_lambda(my_id: str, name: str) -> Response:
# THEN
assert result["statusCode"] == 200
- assert result["headers"]["Content-Type"] == content_types.TEXT_HTML
+ assert result["multiValueHeaders"]["Content-Type"] == [content_types.TEXT_HTML]
assert result["body"] == "path"
@@ -177,12 +299,16 @@ def delete_func():
def patch_func():
raise RuntimeError()
+ @app.head("/no_matching_head")
+ def head_func():
+ raise RuntimeError()
+
def handler(event, context):
return app.resolve(event, context)
# Also check the route configurations
- routes = app._routes
- assert len(routes) == 5
+ routes = app._static_routes
+ assert len(routes) == 6
for route in routes:
if route.func == get_func:
assert route.method == "GET"
@@ -194,25 +320,27 @@ def handler(event, context):
assert route.method == "DELETE"
elif route.func == patch_func:
assert route.method == "PATCH"
+ elif route.func == head_func:
+ assert route.method == "HEAD"
# WHEN calling the handler
# THEN return a 404
result = handler(LOAD_GW_EVENT, None)
assert result["statusCode"] == 404
# AND cors headers are not returned
- assert "Access-Control-Allow-Origin" not in result["headers"]
+ assert "Access-Control-Allow-Origin" not in result["multiValueHeaders"]
def test_cors():
- # GIVEN a function with cors=True
+ # GIVEN a function
# AND http method set to GET
- app = ApiGatewayResolver()
+ app = ApiGatewayResolver(cors=CORSConfig("https://aws.amazon.com", allow_credentials=True))
- @app.get("/my/path", cors=True)
+ @app.get("/my/path")
def with_cors() -> Response:
return Response(200, content_types.TEXT_HTML, "test")
- @app.get("/without-cors")
+ @app.get("/without-cors", cors=False)
def without_cors() -> Response:
return Response(200, content_types.TEXT_HTML, "test")
@@ -223,17 +351,80 @@ def handler(event, context):
result = handler(LOAD_GW_EVENT, None)
# THEN the headers should include cors headers
- assert "headers" in result
- headers = result["headers"]
- assert headers["Content-Type"] == content_types.TEXT_HTML
- assert headers["Access-Control-Allow-Origin"] == "*"
+ assert "multiValueHeaders" in result
+ headers = result["multiValueHeaders"]
+ assert headers["Content-Type"] == [content_types.TEXT_HTML]
+ assert headers["Access-Control-Allow-Origin"] == ["https://aws.amazon.com"]
+ assert "Access-Control-Allow-Credentials" in headers
+ assert headers["Access-Control-Allow-Headers"] == [",".join(sorted(CORSConfig._REQUIRED_HEADERS))]
+
+ # THEN for routes without cors flag return no cors headers
+ mock_event = {"path": "/without-cors", "httpMethod": "GET"}
+ result = handler(mock_event, None)
+ assert "Access-Control-Allow-Origin" not in result["multiValueHeaders"]
+
+
+def test_cors_no_request_origin():
+ # GIVEN a function
+ # AND http method set to GET
+ app = ApiGatewayResolver(cors=CORSConfig())
+
+ @app.get("/my/path")
+ def with_cors() -> Response:
+ return Response(200, content_types.TEXT_HTML, "test")
+
+ def handler(event, context):
+ return app.resolve(event, context)
+
+ event = LOAD_GW_EVENT_NO_ORIGIN
+
+ # WHEN calling the event handler
+ result = handler(event, None)
+
+ # THEN the headers should include cors headers
+ assert "multiValueHeaders" in result
+ headers = result["multiValueHeaders"]
+ assert headers["Content-Type"] == [content_types.TEXT_HTML]
assert "Access-Control-Allow-Credentials" not in headers
- assert headers["Access-Control-Allow-Headers"] == ",".join(sorted(CORSConfig._REQUIRED_HEADERS))
+ assert "Access-Control-Allow-Origin" not in result["multiValueHeaders"]
+
+
+def test_cors_allow_all_request_origins():
+ # GIVEN a function
+ # AND http method set to GET
+ app = ApiGatewayResolver(
+ cors=CORSConfig(
+ allow_origin="*",
+ allow_credentials=True,
+ ),
+ )
+
+ @app.get("/my/path")
+ def with_cors() -> Response:
+ return Response(200, content_types.TEXT_HTML, "test")
+
+ @app.get("/without-cors", cors=False)
+ def without_cors() -> Response:
+ return Response(200, content_types.TEXT_HTML, "test")
+
+ def handler(event, context):
+ return app.resolve(event, context)
+
+ # WHEN calling the event handler
+ result = handler(LOAD_GW_EVENT, None)
+
+ # THEN the headers should include cors headers
+ assert "multiValueHeaders" in result
+ headers = result["multiValueHeaders"]
+ assert headers["Content-Type"] == [content_types.TEXT_HTML]
+ assert headers["Access-Control-Allow-Origin"] == ["*"]
+ assert "Access-Control-Allow-Credentials" not in headers
+ assert headers["Access-Control-Allow-Headers"] == [",".join(sorted(CORSConfig._REQUIRED_HEADERS))]
# THEN for routes without cors flag return no cors headers
- mock_event = {"path": "/my/request", "httpMethod": "GET"}
+ mock_event = {"path": "/without-cors", "httpMethod": "GET"}
result = handler(mock_event, None)
- assert "Access-Control-Allow-Origin" not in result["headers"]
+ assert "Access-Control-Allow-Origin" not in result["multiValueHeaders"]
def test_cors_preflight_body_is_empty_not_null():
@@ -249,6 +440,89 @@ def test_cors_preflight_body_is_empty_not_null():
assert result["body"] == ""
+def test_override_route_compress_parameter():
+ # GIVEN a function that has compress=True
+ # AND an event with a "Accept-Encoding" that include gzip
+ # AND the Response object with compress=False
+ app = ApiGatewayResolver()
+ mock_event = {"path": "/my/request", "httpMethod": "GET", "headers": {"Accept-Encoding": "deflate, gzip"}}
+ expected_value = '{"test": "value"}'
+
+ @app.get("/my/request", compress=True)
+ def with_compression() -> Response:
+ return Response(200, content_types.APPLICATION_JSON, expected_value, compress=False)
+
+ def handler(event, context):
+ return app.resolve(event, context)
+
+ # WHEN calling the event handler
+ result = handler(mock_event, None)
+
+ # THEN the response is not compressed
+ assert result["isBase64Encoded"] is False
+ assert result["body"] == expected_value
+ assert result["multiValueHeaders"].get("Content-Encoding") is None
+
+
+@pytest.mark.parametrize(
+ "headers",
+ [
+ {"headers": {"Accept-Encoding": "deflate, gzip"}},
+ {"multiValueHeaders": {"Accept-Encoding": ["deflate, gzip"]}},
+ {"multiValueHeaders": {"Accept-Encoding": ["deflate", "gzip"]}},
+ ],
+)
+def test_response_with_compress_enabled(headers: dict):
+ # GIVEN a function
+ # AND an event with a "Accept-Encoding" that include gzip
+ # AND the Response object with compress=True
+ app = ApiGatewayResolver()
+ mock_event = {"path": "/my/request", "httpMethod": "GET", **headers}
+ expected_value = '{"test": "value"}'
+
+ @app.get("/my/request")
+ def route_without_compression() -> Response:
+ return Response(200, content_types.APPLICATION_JSON, expected_value, compress=True)
+
+ def handler(event, context):
+ return app.resolve(event, context)
+
+ # WHEN calling the event handler
+ result = handler(mock_event, None)
+
+ # THEN then gzip the response and base64 encode as a string
+ assert result["isBase64Encoded"] is True
+ body = result["body"]
+ assert isinstance(body, str)
+ decompress = zlib.decompress(base64.b64decode(body), wbits=zlib.MAX_WBITS | 16).decode("UTF-8")
+ assert decompress == expected_value
+ headers = result["multiValueHeaders"]
+ assert headers["Content-Encoding"] == ["gzip"]
+
+
+def test_response_is_json_without_content_type():
+ response = Response(200, None, "")
+
+ assert response.is_json() is False
+
+
+def test_response_is_json_with_json_content_type():
+ response = Response(200, content_types.APPLICATION_JSON, "")
+ assert response.is_json() is True
+
+
+def test_response_is_json_with_multiple_json_content_types():
+ response = Response(
+ 200,
+ None,
+ "",
+ {
+ "Content-Type": [content_types.APPLICATION_JSON, content_types.APPLICATION_JSON],
+ },
+ )
+ assert response.is_json() is True
+
+
def test_compress():
# GIVEN a function that has compress=True
# AND an event with a "Accept-Encoding" that include gzip
@@ -272,8 +546,8 @@ def handler(event, context):
assert isinstance(body, str)
decompress = zlib.decompress(base64.b64decode(body), wbits=zlib.MAX_WBITS | 16).decode("UTF-8")
assert decompress == expected_value
- headers = result["headers"]
- assert headers["Content-Encoding"] == "gzip"
+ headers = result["multiValueHeaders"]
+ assert headers["Content-Encoding"] == ["gzip"]
def test_base64_encode():
@@ -292,8 +566,8 @@ def read_image() -> Response:
assert result["isBase64Encoded"] is True
body = result["body"]
assert isinstance(body, str)
- headers = result["headers"]
- assert headers["Content-Encoding"] == "gzip"
+ headers = result["multiValueHeaders"]
+ assert headers["Content-Encoding"] == ["gzip"]
def test_compress_no_accept_encoding():
@@ -348,9 +622,9 @@ def handler(event, context):
result = handler({"path": "/success", "httpMethod": "GET"}, None)
# THEN return the set Cache-Control
- headers = result["headers"]
- assert headers["Content-Type"] == content_types.TEXT_HTML
- assert headers["Cache-Control"] == "max-age=600"
+ headers = result["multiValueHeaders"]
+ assert headers["Content-Type"] == [content_types.TEXT_HTML]
+ assert headers["Cache-Control"] == ["max-age=600"]
def test_cache_control_non_200():
@@ -369,9 +643,9 @@ def handler(event, context):
result = handler({"path": "/fails", "httpMethod": "DELETE"}, None)
# THEN return a Cache-Control of "no-cache"
- headers = result["headers"]
- assert headers["Content-Type"] == content_types.TEXT_HTML
- assert headers["Cache-Control"] == "no-cache"
+ headers = result["multiValueHeaders"]
+ assert headers["Content-Type"] == [content_types.TEXT_HTML]
+ assert headers["Cache-Control"] == ["no-cache"]
def test_rest_api():
@@ -380,7 +654,7 @@ def test_rest_api():
expected_dict = {"foo": "value", "second": Decimal("100.01")}
@app.get("/my/path")
- def rest_func() -> Dict:
+ def rest_func() -> dict:
return expected_dict
# WHEN calling the event handler
@@ -388,7 +662,7 @@ def rest_func() -> Dict:
# THEN automatically process this as a json rest api response
assert result["statusCode"] == 200
- assert result["headers"]["Content-Type"] == content_types.APPLICATION_JSON
+ assert result["multiValueHeaders"]["Content-Type"] == [content_types.APPLICATION_JSON]
expected_str = json.dumps(expected_dict, separators=(",", ":"), indent=None, cls=Encoder)
assert result["body"] == expected_str
@@ -403,7 +677,7 @@ def rest_func() -> Response:
status_code=404,
content_type="used-if-not-set-in-header",
body="Not found",
- headers={"Content-Type": "header-content-type-wins", "custom": "value"},
+ headers={"Content-Type": ["header-content-type-wins"], "custom": ["value"]},
)
# WHEN calling the event handler
@@ -411,11 +685,39 @@ def rest_func() -> Response:
# THEN the result can include some additional field control like overriding http headers
assert result["statusCode"] == 404
- assert result["headers"]["Content-Type"] == "header-content-type-wins"
- assert result["headers"]["custom"] == "value"
+ assert result["multiValueHeaders"]["Content-Type"] == ["header-content-type-wins"]
+ assert result["multiValueHeaders"]["custom"] == ["value"]
assert result["body"] == "Not found"
+def test_cors_multi_origin():
+ # GIVEN a custom cors configuration with multiple origins
+ cors_config = CORSConfig(allow_origin="https://origin1", extra_origins=["https://origin2", "https://origin3"])
+ app = ApiGatewayResolver(cors=cors_config)
+
+ @app.get("/cors")
+ def get_with_cors():
+ return {}
+
+ # WHEN calling the event handler with the correct Origin
+ event = {"path": "/cors", "httpMethod": "GET", "headers": {"Origin": "https://origin3"}}
+ result = app(event, None)
+
+ # THEN routes by default return the custom cors headers
+ headers = result["multiValueHeaders"]
+ assert headers["Content-Type"] == [content_types.APPLICATION_JSON]
+ assert headers["Access-Control-Allow-Origin"] == ["https://origin3"]
+
+ # WHEN calling the event handler with the wrong origin
+ event = {"path": "/cors", "httpMethod": "GET", "headers": {"Origin": "https://wrong.origin"}}
+ result = app(event, None)
+
+ # THEN routes by default return the custom cors headers
+ headers = result["multiValueHeaders"]
+ assert headers["Content-Type"] == [content_types.APPLICATION_JSON]
+ assert "Access-Control-Allow-Origin" not in headers
+
+
def test_custom_cors_config():
# GIVEN a custom cors configuration
allow_header = ["foo2"]
@@ -427,7 +729,7 @@ def test_custom_cors_config():
allow_credentials=True,
)
app = ApiGatewayResolver(cors=cors_config)
- event = {"path": "/cors", "httpMethod": "GET"}
+ event = {"path": "/cors", "httpMethod": "GET", "headers": {"Origin": "https://foo1"}}
@app.get("/cors")
def get_with_cors():
@@ -441,16 +743,16 @@ def another_one():
result = app(event, None)
# THEN routes by default return the custom cors headers
- assert "headers" in result
- headers = result["headers"]
- assert headers["Content-Type"] == content_types.APPLICATION_JSON
- assert headers["Access-Control-Allow-Origin"] == cors_config.allow_origin
- expected_allows_headers = ",".join(sorted(set(allow_header + cors_config._REQUIRED_HEADERS)))
+ assert "multiValueHeaders" in result
+ headers = result["multiValueHeaders"]
+ assert headers["Content-Type"] == [content_types.APPLICATION_JSON]
+ assert headers["Access-Control-Allow-Origin"] == ["https://foo1"]
+ expected_allows_headers = [",".join(sorted(set(allow_header + cors_config._REQUIRED_HEADERS)))]
assert headers["Access-Control-Allow-Headers"] == expected_allows_headers
- assert headers["Access-Control-Expose-Headers"] == ",".join(cors_config.expose_headers)
- assert headers["Access-Control-Max-Age"] == str(cors_config.max_age)
+ assert headers["Access-Control-Expose-Headers"] == [",".join(cors_config.expose_headers)]
+ assert headers["Access-Control-Max-Age"] == [str(cors_config.max_age)]
assert "Access-Control-Allow-Credentials" in headers
- assert headers["Access-Control-Allow-Credentials"] == "true"
+ assert headers["Access-Control-Allow-Credentials"] == ["true"]
# AND custom cors was set on the app
assert isinstance(app._cors, CORSConfig)
@@ -459,7 +761,7 @@ def another_one():
# AND routes without cors don't include "Access-Control" headers
event = {"path": "/another-one", "httpMethod": "GET"}
result = app(event, None)
- headers = result["headers"]
+ headers = result["multiValueHeaders"]
assert "Access-Control-Allow-Origin" not in headers
@@ -474,7 +776,7 @@ def test_no_content_response():
# THEN return an None body and no Content-Type header
assert result["statusCode"] == response.status_code
assert result["body"] is None
- headers = result["headers"]
+ headers = result["multiValueHeaders"]
assert "Content-Type" not in headers
@@ -487,9 +789,9 @@ def test_no_matches_with_cors():
result = app({"path": "/another-one", "httpMethod": "GET"}, None)
# THEN return a 404
- # AND cors headers are returned
+ # AND cors headers are NOT returned (because no Origin header was passed in)
assert result["statusCode"] == 404
- assert "Access-Control-Allow-Origin" in result["headers"]
+ assert "Access-Control-Allow-Origin" not in result["multiValueHeaders"]
assert "Not found" in result["body"]
@@ -499,28 +801,25 @@ def test_cors_preflight():
app = ApiGatewayResolver(cors=CORSConfig())
@app.get("/foo")
- def foo_cors():
- ...
+ def foo_cors(): ...
@app.route(method="delete", rule="/foo")
- def foo_delete_cors():
- ...
+ def foo_delete_cors(): ...
@app.post("/foo", cors=False)
- def post_no_cors():
- ...
+ def post_no_cors(): ...
# WHEN calling the handler
- result = app({"path": "/foo", "httpMethod": "OPTIONS"}, None)
+ result = app({"path": "/foo", "httpMethod": "OPTIONS", "headers": {"Origin": "http://example.org"}}, None)
# THEN return no content
# AND include Access-Control-Allow-Methods of the cors methods used
assert result["statusCode"] == 204
assert result["body"] == ""
- headers = result["headers"]
+ headers = result["multiValueHeaders"]
assert "Content-Type" not in headers
- assert "Access-Control-Allow-Origin" in result["headers"]
- assert headers["Access-Control-Allow-Methods"] == "DELETE,GET,OPTIONS"
+ assert "Access-Control-Allow-Origin" in result["multiValueHeaders"]
+ assert headers["Access-Control-Allow-Methods"] == [",".join(sorted(["DELETE", "GET", "OPTIONS"]))]
def test_custom_preflight_response():
@@ -529,29 +828,31 @@ def test_custom_preflight_response():
# AND the request matches this custom preflight route
app = ApiGatewayResolver(cors=CORSConfig())
- @app.route(method="OPTIONS", rule="/some-call", cors=True)
+ @app.route(method="OPTIONS", rule="/some-call")
def custom_preflight():
return Response(
status_code=200,
content_type=content_types.TEXT_HTML,
body="Foo",
- headers={"Access-Control-Allow-Methods": "CUSTOM"},
+ headers={"Access-Control-Allow-Methods": ["CUSTOM"]},
)
- @app.route(method="CUSTOM", rule="/some-call", cors=True)
- def custom_method():
- ...
+ @app.route(method="CUSTOM", rule="/some-call")
+ def custom_method(): ...
+
+ # AND the request includes an origin
+ headers = {"Origin": "https://example.org"}
# WHEN calling the handler
- result = app({"path": "/some-call", "httpMethod": "OPTIONS"}, None)
+ result = app({"path": "/some-call", "httpMethod": "OPTIONS", "headers": headers}, None)
# THEN return the custom preflight response
assert result["statusCode"] == 200
assert result["body"] == "Foo"
- headers = result["headers"]
- assert headers["Content-Type"] == content_types.TEXT_HTML
- assert "Access-Control-Allow-Origin" in result["headers"]
- assert headers["Access-Control-Allow-Methods"] == "CUSTOM"
+ headers = result["multiValueHeaders"]
+ assert headers["Content-Type"] == [content_types.TEXT_HTML]
+ assert "Access-Control-Allow-Origin" in result["multiValueHeaders"]
+ assert headers["Access-Control-Allow-Methods"] == ["CUSTOM"]
def test_service_error_responses(json_dump):
@@ -569,7 +870,7 @@ def bad_request_error():
# THEN return the bad request error response
# AND status code equals 400
assert result["statusCode"] == 400
- assert result["headers"]["Content-Type"] == content_types.APPLICATION_JSON
+ assert result["multiValueHeaders"]["Content-Type"] == [content_types.APPLICATION_JSON]
expected = {"statusCode": 400, "message": "Missing required parameter"}
assert result["body"] == json_dump(expected)
@@ -584,10 +885,25 @@ def unauthorized_error():
# THEN return the unauthorized error response
# AND status code equals 401
assert result["statusCode"] == 401
- assert result["headers"]["Content-Type"] == content_types.APPLICATION_JSON
+ assert result["multiValueHeaders"]["Content-Type"] == [content_types.APPLICATION_JSON]
expected = {"statusCode": 401, "message": "Unauthorized"}
assert result["body"] == json_dump(expected)
+ # GIVEN a ForbiddenError
+ @app.get(rule="/forbidden-error", cors=False)
+ def forbidden_error():
+ raise ForbiddenError("Access denied")
+
+ # WHEN calling the handler
+ # AND path is /forbidden-error
+ result = app({"path": "/forbidden-error", "httpMethod": "GET"}, None)
+ # THEN return the forbidden error response
+ # AND status code equals 403
+ assert result["statusCode"] == 403
+ assert result["multiValueHeaders"]["Content-Type"] == [content_types.APPLICATION_JSON]
+ expected = {"statusCode": 403, "message": "Access denied"}
+ assert result["body"] == json_dump(expected)
+
# GIVEN an NotFoundError
@app.get(rule="/not-found-error", cors=False)
def not_found_error():
@@ -599,10 +915,40 @@ def not_found_error():
# THEN return the not found error response
# AND status code equals 404
assert result["statusCode"] == 404
- assert result["headers"]["Content-Type"] == content_types.APPLICATION_JSON
+ assert result["multiValueHeaders"]["Content-Type"] == [content_types.APPLICATION_JSON]
expected = {"statusCode": 404, "message": "Not found"}
assert result["body"] == json_dump(expected)
+ # GIVEN a RequestTimeoutError
+ @app.get(rule="/request-timeout-error", cors=False)
+ def request_timeout_error():
+ raise RequestTimeoutError("Request timed out")
+
+ # WHEN calling the handler
+ # AND path is /request-timeout-error
+ result = app({"path": "/request-timeout-error", "httpMethod": "GET"}, None)
+ # THEN return the request timeout error response
+ # AND status code equals 408
+ assert result["statusCode"] == 408
+ assert result["multiValueHeaders"]["Content-Type"] == [content_types.APPLICATION_JSON]
+ expected = {"statusCode": 408, "message": "Request timed out"}
+ assert result["body"] == json_dump(expected)
+
+ # GIVEN a RequestEntityTooLargeError
+ @app.get(rule="/request-entity-too-large-error", cors=False)
+ def request_entity_too_large_error():
+ raise RequestEntityTooLargeError("Request payload too large")
+
+ # WHEN calling the handler
+ # AND path is /request-entity-too-large-error
+ result = app({"path": "/request-entity-too-large-error", "httpMethod": "GET"}, None)
+ # THEN return the request entity too large error response
+ # AND status code equals 413
+ assert result["statusCode"] == 413
+ assert result["multiValueHeaders"]["Content-Type"] == [content_types.APPLICATION_JSON]
+ expected = {"statusCode": 413, "message": "Request payload too large"}
+ assert result["body"] == json_dump(expected)
+
# GIVEN an InternalServerError
@app.get(rule="/internal-server-error", cors=False)
def internal_server_error():
@@ -614,12 +960,27 @@ def internal_server_error():
# THEN return the internal server error response
# AND status code equals 500
assert result["statusCode"] == 500
- assert result["headers"]["Content-Type"] == content_types.APPLICATION_JSON
+ assert result["multiValueHeaders"]["Content-Type"] == [content_types.APPLICATION_JSON]
expected = {"statusCode": 500, "message": "Internal server error"}
assert result["body"] == json_dump(expected)
+ # GIVEN a ServiceUnavailableError
+ @app.get(rule="/service-unavailable-error", cors=False)
+ def service_unavailable_error():
+ raise ServiceUnavailableError("Service is temporarily unavailable")
+
+ # WHEN calling the handler
+ # AND path is /service-unavailable-error
+ result = app({"path": "/service-unavailable-error", "httpMethod": "GET"}, None)
+ # THEN return the service unavailable error response
+ # AND status code equals 503
+ assert result["statusCode"] == 503
+ assert result["multiValueHeaders"]["Content-Type"] == [content_types.APPLICATION_JSON]
+ expected = {"statusCode": 503, "message": "Service is temporarily unavailable"}
+ assert result["body"] == json_dump(expected)
+
# GIVEN an ServiceError with a custom status code
- @app.get(rule="/service-error", cors=True)
+ @app.get(rule="/service-error")
def service_error():
raise ServiceError(502, "Something went wrong!")
@@ -629,8 +990,9 @@ def service_error():
# THEN return the service error response
# AND status code equals 502
assert result["statusCode"] == 502
- assert result["headers"]["Content-Type"] == content_types.APPLICATION_JSON
- assert "Access-Control-Allow-Origin" in result["headers"]
+ assert result["multiValueHeaders"]["Content-Type"] == [content_types.APPLICATION_JSON]
+ # Because no Origin was passed in, there is not Allow-Origin on the output
+ assert "Access-Control-Allow-Origin" not in result["multiValueHeaders"]
expected = {"statusCode": 502, "message": "Something went wrong!"}
assert result["body"] == json_dump(expected)
@@ -653,8 +1015,8 @@ def raises_error():
# AND include the exception traceback in the response
assert result["statusCode"] == 500
assert "Traceback (most recent call last)" in result["body"]
- headers = result["headers"]
- assert headers["Content-Type"] == content_types.TEXT_PLAIN
+ headers = result["multiValueHeaders"]
+ assert headers["Content-Type"] == [content_types.TEXT_PLAIN]
def test_debug_unhandled_exceptions_debug_off():
@@ -676,20 +1038,11 @@ def raises_error():
assert e.value.args == ("Foo",)
-def test_debug_mode_environment_variable(monkeypatch):
- # GIVEN a debug mode environment variable is set
- monkeypatch.setenv(constants.EVENT_HANDLER_DEBUG_ENV, "true")
- app = ApiGatewayResolver()
-
- # WHEN calling app._debug
- # THEN the debug mode is enabled
- assert app._debug
-
-
def test_powertools_dev_sets_debug_mode(monkeypatch):
# GIVEN a debug mode environment variable is set
monkeypatch.setenv(constants.POWERTOOLS_DEV_ENV, "true")
- app = ApiGatewayResolver()
+ with pytest.warns(UserWarning, match="POWERTOOLS_DEV environment variable is enabled."):
+ app = ApiGatewayResolver()
# WHEN calling app._debug
# THEN the debug mode is enabled
@@ -722,7 +1075,9 @@ def test_debug_print_event(capsys):
# THEN print the event
out, err = capsys.readouterr()
- assert json.loads(out) == event
+ assert "\n" in out
+ output: str = out.split("\n")[0]
+ assert json.loads(output) == event
def test_similar_dynamic_routes():
@@ -731,17 +1086,17 @@ def test_similar_dynamic_routes():
event = deepcopy(LOAD_GW_EVENT)
# WHEN
- # r'^/accounts/(?P\\w+\\b)$' # noqa: E800
+ # r'^/accounts/(?P\\w+\\b)$' # noqa: ERA001
@app.get("/accounts/")
def get_account(account_id: str):
assert account_id == "single_account"
- # r'^/accounts/(?P\\w+\\b)/source_networks$' # noqa: E800
+ # r'^/accounts/(?P\\w+\\b)/source_networks$' # noqa: ERA001
@app.get("/accounts//source_networks")
def get_account_networks(account_id: str):
assert account_id == "nested_account"
- # r'^/accounts/(?P\\w+\\b)/source_networks/(?P\\w+\\b)$' # noqa: E800
+ # r'^/accounts/(?P\\w+\\b)/source_networks/(?P\\w+\\b)$' # noqa: ERA001
@app.get("/accounts//source_networks/")
def get_network_account(account_id: str, network_id: str):
assert account_id == "nested_account"
@@ -767,17 +1122,17 @@ def test_similar_dynamic_routes_with_whitespaces():
event = deepcopy(LOAD_GW_EVENT)
# WHEN
- # r'^/accounts/(?P\\w+\\b)$' # noqa: E800
+ # r'^/accounts/(?P\\w+\\b)$' # noqa: ERA001
@app.get("/accounts/")
def get_account(account_id: str):
assert account_id == "single account"
- # r'^/accounts/(?P\\w+\\b)/source_networks$' # noqa: E800
+ # r'^/accounts/(?P\\w+\\b)/source_networks$' # noqa: ERA001
@app.get("/accounts//source_networks")
def get_account_networks(account_id: str):
assert account_id == "nested account"
- # r'^/accounts/(?P\\w+\\b)/source_networks/(?P\\w+\\b)$' # noqa: E800
+ # r'^/accounts/(?P\\w+\\b)/source_networks/(?P\\w+\\b)$' # noqa: ERA001
@app.get("/accounts//source_networks/")
def get_network_account(account_id: str, network_id: str):
assert account_id == "nested account"
@@ -802,7 +1157,7 @@ def get_network_account(account_id: str, network_id: str):
[
pytest.param(123456789, id="num"),
pytest.param("user@example.com", id="email"),
- pytest.param("-._~'!*:@,;()", id="safe-rfc3986"),
+ pytest.param("-._~'!*:@,;()=+&$", id="safe-rfc3986"),
pytest.param("%<>[]{}|^", id="unsafe-rfc3986"),
],
)
@@ -843,23 +1198,16 @@ def custom_serializer(data) -> str:
app = ApiGatewayResolver(serializer=custom_serializer)
- class Color(Enum):
- RED = 1
- BLUE = 2
-
- @app.get("/colors")
- def get_color() -> Dict:
- return {
- "color": Color.RED,
- "variations": {"light", "dark"},
- }
+ @app.get("/custom_serializer")
+ def get_custom_values() -> dict:
+ return {"values": deque(["name", "age"])}
# WHEN calling handler
- response = app({"httpMethod": "GET", "path": "/colors"}, None)
+ response = app({"httpMethod": "GET", "path": "/custom_serializer"}, None)
# THEN then use the custom serializer
body = response["body"]
- expected = '{"color": 1, "variations": ["dark", "light"]}'
+ expected = '{"values": ["age", "name"]}'
assert expected == body
@@ -881,8 +1229,7 @@ def pay_foo():
raise ValueError("should not be matching")
@app.get("/foo")
- def foo():
- ...
+ def foo(): ...
# WHEN calling handler
response = app({"httpMethod": "GET", "path": path}, None)
@@ -891,6 +1238,36 @@ def foo():
assert response["statusCode"] == 200
+@pytest.mark.parametrize(
+ "path",
+ [
+ pytest.param("/stg/foo", id="path matched pay prefix"),
+ pytest.param("/dev/foo", id="path matched pay prefix with multiple numbers"),
+ pytest.param("/foo", id="path does not start with any of the prefixes"),
+ ],
+)
+def test_remove_prefix_by_regex(path: str):
+ app = ApiGatewayResolver(strip_prefixes=[re.compile(r"/(dev|stg)")])
+
+ @app.get("/foo")
+ def foo(): ...
+
+ response = app({"httpMethod": "GET", "path": path}, None)
+
+ assert response["statusCode"] == 200
+
+
+def test_empty_path_when_using_regexes():
+ app = ApiGatewayResolver(strip_prefixes=[re.compile(r"/(dev|stg)")])
+
+ @app.get("/")
+ def foo(): ...
+
+ response = app({"httpMethod": "GET", "path": "/dev"}, None)
+
+ assert response["statusCode"] == 200
+
+
@pytest.mark.parametrize(
"prefix",
[
@@ -906,8 +1283,7 @@ def test_ignore_invalid(prefix):
app = ApiGatewayResolver(strip_prefixes=prefix)
@app.get("/foo/status")
- def foo():
- ...
+ def foo(): ...
# WHEN calling handler
response = app({"httpMethod": "GET", "path": "/foo/status"}, None)
@@ -951,7 +1327,26 @@ def base():
# THEN process event correctly
assert result["statusCode"] == 200
- assert result["headers"]["Content-Type"] == content_types.APPLICATION_JSON
+ assert result["multiValueHeaders"]["Content-Type"] == [content_types.APPLICATION_JSON]
+
+
+def test_api_gateway_app_with_strip_prefix_and_route_prefix():
+ # GIVEN all routes are stripped from its version e.g., /v1
+ app = ApiGatewayResolver(strip_prefixes=["/v1"])
+ router = Router()
+
+ event = {"httpMethod": "GET", "path": "/v1/users/pat", "resource": "/users"}
+
+ @router.get("")
+ def base(user_id: str):
+ return {"user": user_id}
+
+ # WHEN a router is included prefixing all routes with "/users/"
+ app.include_router(router, prefix="/users/")
+ result = app(event, {})
+
+ # THEN route correctly to the registered route after stripping each prefix (global + router)
+ assert result["statusCode"] == 200
def test_api_gateway_app_router():
@@ -969,7 +1364,7 @@ def foo():
# THEN process event correctly
assert result["statusCode"] == 200
- assert result["headers"]["Content-Type"] == content_types.APPLICATION_JSON
+ assert result["multiValueHeaders"]["Content-Type"] == [content_types.APPLICATION_JSON]
def test_api_gateway_app_router_with_params():
@@ -995,7 +1390,7 @@ def foo(account_id):
# THEN process event correctly
assert result["statusCode"] == 200
- assert result["headers"]["Content-Type"] == content_types.APPLICATION_JSON
+ assert result["multiValueHeaders"]["Content-Type"] == [content_types.APPLICATION_JSON]
def test_api_gateway_app_router_with_prefix():
@@ -1014,7 +1409,7 @@ def foo():
# THEN process event correctly
assert result["statusCode"] == 200
- assert result["headers"]["Content-Type"] == content_types.APPLICATION_JSON
+ assert result["multiValueHeaders"]["Content-Type"] == [content_types.APPLICATION_JSON]
def test_api_gateway_app_router_with_prefix_equals_path():
@@ -1034,7 +1429,7 @@ def foo():
# THEN process event correctly
assert result["statusCode"] == 200
- assert result["headers"]["Content-Type"] == content_types.APPLICATION_JSON
+ assert result["multiValueHeaders"]["Content-Type"] == [content_types.APPLICATION_JSON]
def test_api_gateway_app_router_with_different_methods():
@@ -1065,7 +1460,7 @@ def patch_func():
app.include_router(router)
# Also check check the route configurations
- routes = app._routes
+ routes = app._static_routes
assert len(routes) == 5
for route in routes:
if route.func == get_func:
@@ -1084,7 +1479,7 @@ def patch_func():
result = app(LOAD_GW_EVENT, None)
assert result["statusCode"] == 404
# AND cors headers are not returned
- assert "Access-Control-Allow-Origin" not in result["headers"]
+ assert "Access-Control-Allow-Origin" not in result["multiValueHeaders"]
def test_duplicate_routes():
@@ -1104,7 +1499,8 @@ def get_func():
def get_func_another_duplicate():
raise RuntimeError()
- app.include_router(router)
+ with pytest.warns(UserWarning, match="A route like this was already registered"):
+ app.include_router(router)
# WHEN calling the handler
result = app(LOAD_GW_EVENT, None)
@@ -1143,11 +1539,11 @@ def foo(account_id):
# THEN events are processed correctly
assert get_result["statusCode"] == 200
- assert get_result["headers"]["Content-Type"] == content_types.APPLICATION_JSON
+ assert get_result["multiValueHeaders"]["Content-Type"] == [content_types.APPLICATION_JSON]
assert post_result["statusCode"] == 200
- assert post_result["headers"]["Content-Type"] == content_types.APPLICATION_JSON
+ assert post_result["multiValueHeaders"]["Content-Type"] == [content_types.APPLICATION_JSON]
assert put_result["statusCode"] == 404
- assert put_result["headers"]["Content-Type"] == content_types.APPLICATION_JSON
+ assert put_result["multiValueHeaders"]["Content-Type"] == [content_types.APPLICATION_JSON]
def test_api_gateway_app_router_access_to_resolver():
@@ -1166,7 +1562,7 @@ def foo():
result = app(LOAD_GW_EVENT, {})
assert result["statusCode"] == 200
- assert result["headers"]["Content-Type"] == content_types.APPLICATION_JSON
+ assert result["multiValueHeaders"]["Content-Type"] == [content_types.APPLICATION_JSON]
def test_exception_handler():
@@ -1192,7 +1588,37 @@ def get_lambda() -> Response:
# THEN call the exception_handler
assert result["statusCode"] == 418
- assert result["headers"]["Content-Type"] == content_types.TEXT_HTML
+ assert result["multiValueHeaders"]["Content-Type"] == [content_types.TEXT_HTML]
+ assert result["body"] == "Foo!"
+
+
+def test_exception_handler_with_route():
+ app = ApiGatewayResolver()
+ # GIVEN a Router object with an exception handler defined for ValueError
+ router = Router()
+
+ @router.exception_handler(ValueError)
+ def handle_value_error(ex: ValueError):
+ print(f"request path is '{app.current_event.path}'")
+ return Response(
+ status_code=418,
+ content_type=content_types.TEXT_HTML,
+ body=str(ex),
+ )
+
+ @router.get("/my/path")
+ def get_lambda() -> Response:
+ raise ValueError("Foo!")
+
+ app.include_router(router)
+
+ # WHEN calling the event handler
+ # AND a ValueError is raised
+ result = app(LOAD_GW_EVENT, {})
+
+ # THEN call the exception_handler from Router
+ assert result["statusCode"] == 418
+ assert result["multiValueHeaders"]["Content-Type"] == [content_types.TEXT_HTML]
assert result["body"] == "Foo!"
@@ -1219,7 +1645,7 @@ def get_lambda() -> Response:
# THEN call the exception_handler
assert result["statusCode"] == 500
- assert result["headers"]["Content-Type"] == content_types.APPLICATION_JSON
+ assert result["multiValueHeaders"]["Content-Type"] == [content_types.APPLICATION_JSON]
assert result["body"] == "CUSTOM ERROR FORMAT"
@@ -1238,7 +1664,7 @@ def handle_not_found(exc: NotFoundError) -> Response:
# THEN call the exception_handler
assert result["statusCode"] == 404
- assert result["headers"]["Content-Type"] == content_types.TEXT_PLAIN
+ assert result["multiValueHeaders"]["Content-Type"] == [content_types.TEXT_PLAIN]
assert result["body"] == "I am a teapot!"
@@ -1276,11 +1702,70 @@ def get_lambda() -> Response:
# THEN call the exception_handler
assert result["statusCode"] == 400
- assert result["headers"]["Content-Type"] == content_types.APPLICATION_JSON
+ assert result["multiValueHeaders"]["Content-Type"] == [content_types.APPLICATION_JSON]
expected = {"statusCode": 400, "message": "Bad request"}
assert result["body"] == json_dump(expected)
+def test_exception_handler_supports_list(json_dump):
+ # GIVEN a resolver with an exception handler defined for a multiple exceptions in a list
+ app = ApiGatewayResolver()
+ event = deepcopy(LOAD_GW_EVENT)
+
+ @app.exception_handler([ValueError, NotFoundError])
+ def multiple_error(ex: Exception):
+ raise BadRequestError("Bad request")
+
+ @app.get("/path/a")
+ def path_a() -> Response:
+ raise ValueError("foo")
+
+ @app.get("/path/b")
+ def path_b() -> Response:
+ raise NotFoundError
+
+ # WHEN calling the app generating each exception
+ for route in ["/path/a", "/path/b"]:
+ event["path"] = route
+ result = app(event, {})
+
+ # THEN call the exception handler in the same way for both exceptions
+ assert result["statusCode"] == 400
+ assert result["multiValueHeaders"]["Content-Type"] == [content_types.APPLICATION_JSON]
+ expected = {"statusCode": 400, "message": "Bad request"}
+ assert result["body"] == json_dump(expected)
+
+
+def test_exception_handler_supports_multiple_decorators(json_dump):
+ # GIVEN a resolver with an exception handler defined with multiple decorators
+ app = ApiGatewayResolver()
+ event = deepcopy(LOAD_GW_EVENT)
+
+ @app.exception_handler(ValueError)
+ @app.exception_handler(NotFoundError)
+ def multiple_error(ex: Exception):
+ raise BadRequestError("Bad request")
+
+ @app.get("/path/a")
+ def path_a() -> Response:
+ raise ValueError("foo")
+
+ @app.get("/path/b")
+ def path_b() -> Response:
+ raise NotFoundError
+
+ # WHEN calling the app generating each exception
+ for route in ["/path/a", "/path/b"]:
+ event["path"] = route
+ result = app(event, {})
+
+ # THEN call the exception handler in the same way for both exceptions
+ assert result["statusCode"] == 400
+ assert result["multiValueHeaders"]["Content-Type"] == [content_types.APPLICATION_JSON]
+ expected = {"statusCode": 400, "message": "Bad request"}
+ assert result["body"] == json_dump(expected)
+
+
def test_event_source_compatibility():
# GIVEN
app = APIGatewayHttpResolver()
@@ -1294,7 +1779,12 @@ def my_path():
@event_source(data_class=APIGatewayProxyEventV2)
def handler(event: APIGatewayProxyEventV2, context):
assert isinstance(event, APIGatewayProxyEventV2)
- return app.resolve(event, context)
+
+ with pytest.warns(
+ UserWarning,
+ match="You don't need to serialize event to Event Source Data Class when using Event Handler",
+ ):
+ return app.resolve(event, context)
# THEN
result = handler(load_event("apiGatewayProxyV2Event.json"), None)
@@ -1369,3 +1859,144 @@ def test_include_router_merges_context():
app.include_router(router)
assert app.context == router.context
+
+
+def test_nested_app_decorator():
+ # GIVEN a Http API V1 proxy type event
+ # with a function registered with two distinct routes
+ app = APIGatewayRestResolver()
+
+ @app.get("/my/path")
+ @app.get("/my/anotherPath")
+ def get_lambda() -> Response:
+ return Response(200, content_types.APPLICATION_JSON, json.dumps({"foo": "value"}))
+
+ # WHEN calling the event handler
+ result = app(LOAD_GW_EVENT, {})
+ result2 = app(load_event("apiGatewayProxyEventAnotherPath.json"), {})
+
+ # THEN process event correctly
+ # AND set the current_event type as APIGatewayProxyEvent
+ assert result["statusCode"] == 200
+ assert result2["statusCode"] == 200
+
+
+def test_nested_router_decorator():
+ # GIVEN a Http API V1 proxy type event
+ # with a function registered with two distinct routes
+ app = APIGatewayRestResolver()
+ router = Router()
+
+ @router.get("/my/path")
+ @router.get("/my/anotherPath")
+ def get_lambda() -> Response:
+ return Response(200, content_types.APPLICATION_JSON, json.dumps({"foo": "value"}))
+
+ app.include_router(router)
+
+ # WHEN calling the event handler
+ result = app(LOAD_GW_EVENT, {})
+ result2 = app(load_event("apiGatewayProxyEventAnotherPath.json"), {})
+
+ # THEN process event correctly
+ # AND set the current_event type as APIGatewayProxyEvent
+ assert result["statusCode"] == 200
+ assert result2["statusCode"] == 200
+
+
+def test_dict_response():
+ # GIVEN a dict is returned
+ app = ApiGatewayResolver()
+
+ @app.get("/lambda")
+ def get_message():
+ return {"message": "success"}
+
+ # WHEN calling handler
+ response = app({"httpMethod": "GET", "path": "/lambda"}, None)
+
+ # THEN the body is correctly formatted, the status code is 200 and the content type is json
+ assert response["statusCode"] == 200
+ assert response["multiValueHeaders"]["Content-Type"] == [content_types.APPLICATION_JSON]
+ response_body = json.loads(response["body"])
+ assert response_body["message"] == "success"
+
+
+def test_dict_response_with_status_code():
+ # GIVEN a dict is returned with a status code
+ app = ApiGatewayResolver()
+
+ @app.get("/lambda")
+ def get_message():
+ return {"message": "success"}, 201
+
+ # WHEN calling handler
+ response = app({"httpMethod": "GET", "path": "/lambda"}, None)
+
+ # THEN the body is correctly formatted, the status code is 201 and the content type is json
+ assert response["statusCode"] == 201
+ assert response["multiValueHeaders"]["Content-Type"] == [content_types.APPLICATION_JSON]
+ response_body = json.loads(response["body"])
+ assert response_body["message"] == "success"
+
+
+def test_route_match_prioritize_full_match():
+ # GIVEN a Http API V1, with a function registered with two routes
+ app = APIGatewayRestResolver()
+ router = Router()
+
+ @router.get("/my/{path}")
+ def dynamic_handler() -> Response:
+ return Response(200, content_types.APPLICATION_JSON, json.dumps({"hello": "dynamic"}))
+
+ @router.get("/my/path")
+ def static_handler() -> Response:
+ return Response(200, content_types.APPLICATION_JSON, json.dumps({"hello": "static"}))
+
+ app.include_router(router)
+
+ # WHEN calling the event handler with /foo/dynamic
+ response = app(LOAD_GW_EVENT, {})
+
+ # THEN the static_handler should have been called, because it fully matches the path directly
+ response_body = json.loads(response["body"])
+ assert response_body["hello"] == "static"
+
+
+def test_alb_empty_response_object():
+ # GIVEN an ALB Resolver
+ app = ALBResolver()
+ event = {"path": "/my/request", "httpMethod": "GET"}
+
+ # AND route returns a Response object with empty body
+ @app.get("/my/request")
+ def opa():
+ return Response(status_code=200, content_type=content_types.APPLICATION_JSON)
+
+ # WHEN calling the event handler
+ result = app(event, {})
+
+ # THEN body should be converted to an empty string
+ assert result["statusCode"] == 200
+ assert result["body"] == ""
+
+
+def test_api_gateway_resolver_with_custom_deserializer():
+ # GIVEN a basic API Gateway resolver
+ app = ApiGatewayResolver(json_body_deserializer=partial(json.loads, parse_float=Decimal))
+
+ @app.post("/my/path")
+ def test_handler():
+ return app.current_event.json_body
+
+ # WHEN calling the event handler
+ event = {}
+ event.update(LOAD_GW_EVENT)
+ event["body"] = '{"amount": 2.2999999999999998}'
+ event["httpMethod"] = "POST"
+
+ result = app(event, {})
+ # THEN process event correctly
+ assert result["statusCode"] == 200
+ assert result["multiValueHeaders"]["Content-Type"] == [content_types.APPLICATION_JSON]
+ assert result["body"] == '{"amount":"2.2999999999999998"}'
diff --git a/tests/functional/event_handler/required_dependencies/test_api_middlewares.py b/tests/functional/event_handler/required_dependencies/test_api_middlewares.py
new file mode 100644
index 00000000000..3f19500f4a5
--- /dev/null
+++ b/tests/functional/event_handler/required_dependencies/test_api_middlewares.py
@@ -0,0 +1,568 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+import pytest
+
+from aws_lambda_powertools.event_handler import content_types
+from aws_lambda_powertools.event_handler.api_gateway import (
+ APIGatewayHttpResolver,
+ ApiGatewayResolver,
+ APIGatewayRestResolver,
+ CORSConfig,
+ ProxyEventType,
+ Response,
+ Router,
+)
+from aws_lambda_powertools.event_handler.exceptions import BadRequestError
+from aws_lambda_powertools.event_handler.middlewares import (
+ BaseMiddlewareHandler,
+ NextMiddleware,
+)
+from aws_lambda_powertools.event_handler.middlewares.schema_validation import (
+ SchemaValidationMiddleware,
+)
+from tests.functional.utils import load_event
+
+if TYPE_CHECKING:
+ from aws_lambda_powertools.event_handler.types import EventHandlerInstance
+
+
+API_REST_EVENT = load_event("apiGatewayProxyEvent.json")
+API_RESTV2_EVENT = load_event("apiGatewayProxyV2Event_GET.json")
+
+
+@pytest.mark.parametrize(
+ "app, event",
+ [
+ (ApiGatewayResolver(proxy_type=ProxyEventType.APIGatewayProxyEvent), API_REST_EVENT),
+ (APIGatewayRestResolver(), API_REST_EVENT),
+ (APIGatewayHttpResolver(), API_RESTV2_EVENT),
+ ],
+)
+def test_route_with_middleware(app: ApiGatewayResolver, event):
+ # define custom middleware to inject new argument - "custom"
+ def middleware_1(app: ApiGatewayResolver, next_middleware: NextMiddleware):
+ # add additional data to Router Context
+ app.append_context(custom="custom")
+ response = next_middleware(app)
+
+ return response
+
+ # define custom middleware to inject new argument - "another_one"
+ def middleware_2(app: ApiGatewayResolver, next_middleware: NextMiddleware):
+ # add additional data to Router Context
+ app.append_context(another_one=6)
+ response = next_middleware(app)
+
+ return response
+
+ @app.get("/my/path", middlewares=[middleware_1, middleware_2])
+ def get_lambda() -> Response:
+ another_one = app.context.get("another_one")
+ custom = app.context.get("custom")
+ assert another_one == 6
+ assert custom == "custom"
+
+ return Response(200, content_types.TEXT_HTML, "foo")
+
+ # WHEN calling the event handler
+ result = app(event, {})
+
+ # THEN process event correctly
+ # AND set the current_event type as APIGatewayProxyEvent
+ assert result["statusCode"] == 200
+ assert result["body"] == "foo"
+
+
+@pytest.mark.parametrize(
+ "app, event, other_event",
+ [
+ (
+ ApiGatewayResolver(proxy_type=ProxyEventType.APIGatewayProxyEvent),
+ API_REST_EVENT,
+ load_event("apiGatewayProxyOtherEvent.json"),
+ ),
+ (
+ APIGatewayRestResolver(),
+ API_REST_EVENT,
+ load_event("apiGatewayProxyOtherEvent.json"),
+ ),
+ (
+ APIGatewayHttpResolver(),
+ API_RESTV2_EVENT,
+ load_event("apiGatewayProxyV2OtherGetEvent.json"),
+ ),
+ ],
+)
+def test_with_router_middleware(app: ApiGatewayResolver, event, other_event):
+ # define custom middleware to inject new argument - "custom"
+ def global_middleware(app: ApiGatewayResolver, next_middleware: NextMiddleware):
+ # add custom data to context
+ app.append_context(custom="custom")
+ response = next_middleware(app)
+
+ return response
+
+ # define custom middleware to inject new argument - "another_one"
+ def middleware_2(app: ApiGatewayResolver, next_middleware: NextMiddleware):
+ # add data to resolver context
+ app.append_context(another_one=6)
+ response = next_middleware(app)
+
+ return response
+
+ app.use([global_middleware])
+
+ @app.get("/my/path", middlewares=[middleware_2])
+ def get_lambda() -> Response:
+ another_one: int = app.context.get("another_one")
+ custom: str = app.context.get("custom")
+ assert another_one == 6
+ assert custom == "custom"
+
+ return Response(200, content_types.TEXT_HTML, "foo")
+
+ # WHEN calling the event handler
+ result = app(event, {})
+
+ # THEN process event correctly
+ # AND set the current_event type as APIGatewayProxyEvent
+ assert result["statusCode"] == 200
+ assert result["body"] == "foo"
+
+ @app.get("/other/path")
+ def get_other_lambda() -> Response:
+ custom: str = app.context.get("custom")
+ assert custom == "custom"
+
+ return Response(200, content_types.TEXT_HTML, "other_foo")
+
+ # WHEN calling the event handler
+ result = app(other_event, {})
+
+ # THEN process event correctly
+ # AND set the current_event type as APIGatewayProxyEvent
+ assert result["statusCode"] == 200
+ assert result["body"] == "other_foo"
+
+
+@pytest.mark.parametrize(
+ "app, event",
+ [
+ (ApiGatewayResolver(proxy_type=ProxyEventType.APIGatewayProxyEvent), API_REST_EVENT),
+ (APIGatewayRestResolver(), API_REST_EVENT),
+ (APIGatewayHttpResolver(), API_RESTV2_EVENT),
+ ],
+)
+def test_dynamic_route_with_middleware(app: ApiGatewayResolver, event):
+ def middleware_one(app: ApiGatewayResolver, next_middleware: NextMiddleware):
+ # inject data into the resolver context
+ app.append_context(injected="injected_value")
+ response = next_middleware(app)
+
+ return response
+
+ @app.get("//", middlewares=[middleware_one])
+ def get_lambda(my_id: str, name: str) -> Response:
+ injected: str = app.context.get("injected")
+ assert name == "my"
+ assert injected == "injected_value"
+
+ return Response(200, content_types.TEXT_HTML, my_id)
+
+ # WHEN calling the event handler
+ result = app(event, {})
+
+ # THEN
+ assert result["statusCode"] == 200
+ assert result["body"] == "path"
+
+
+@pytest.mark.parametrize(
+ "app, event",
+ [
+ (ApiGatewayResolver(proxy_type=ProxyEventType.APIGatewayProxyEvent), API_REST_EVENT),
+ (APIGatewayRestResolver(), API_REST_EVENT),
+ (APIGatewayHttpResolver(), API_RESTV2_EVENT),
+ ],
+)
+def test_middleware_early_return(app: ApiGatewayResolver, event):
+ def middleware_one(app: ApiGatewayResolver, next_middleware):
+ # inject a variable into resolver context
+ app.append_context(injected="injected_value")
+ response = next_middleware(app)
+
+ return response
+
+ def early_return_middleware(app: ApiGatewayResolver, next_middleware: NextMiddleware):
+ assert app.context.get("injected") == "injected_value"
+
+ return Response(400, content_types.TEXT_HTML, "bad_response")
+
+ def not_executed_middleware(app: ApiGatewayResolver, next_middleware: NextMiddleware):
+ # This should never be executed - if it is an excpetion will be raised
+ raise NotImplementedError()
+
+ @app.get("//", middlewares=[middleware_one, early_return_middleware, not_executed_middleware])
+ def get_lambda(my_id: str, name: str) -> Response:
+ assert name == "my"
+ assert app.context.get("injected") == "injected_value"
+
+ return Response(200, content_types.TEXT_HTML, my_id)
+
+ # WHEN calling the event handler
+ result = app(event, {})
+
+ # THEN
+ assert result["statusCode"] == 400
+ assert result["body"] == "bad_response"
+
+
+@pytest.mark.parametrize(
+ "app, event",
+ [
+ (
+ ApiGatewayResolver(proxy_type=ProxyEventType.APIGatewayProxyEvent),
+ load_event("apigatewayeSchemaMiddlwareValidEvent.json"),
+ ),
+ (
+ APIGatewayRestResolver(),
+ load_event("apigatewayeSchemaMiddlwareValidEvent.json"),
+ ),
+ (
+ APIGatewayHttpResolver(),
+ load_event("apiGatewayProxyV2SchemaMiddlwareValidEvent.json"),
+ ),
+ ],
+)
+def test_pass_schema_validation(app: ApiGatewayResolver, event, validation_schema):
+ @app.post("/my/path", middlewares=[SchemaValidationMiddleware(validation_schema)])
+ def post_lambda() -> Response:
+ return Response(200, content_types.TEXT_HTML, "path")
+
+ # WHEN calling the event handler
+ result = app(event, {})
+
+ # THEN
+ assert result["statusCode"] == 200
+ assert result["body"] == "path"
+
+
+@pytest.mark.parametrize(
+ "app, event",
+ [
+ (
+ ApiGatewayResolver(proxy_type=ProxyEventType.APIGatewayProxyEvent),
+ load_event("apigatewayeSchemaMiddlwareInvalidEvent.json"),
+ ),
+ (
+ APIGatewayRestResolver(),
+ load_event("apigatewayeSchemaMiddlwareInvalidEvent.json"),
+ ),
+ (
+ APIGatewayHttpResolver(),
+ load_event("apiGatewayProxyV2SchemaMiddlwareInvalidEvent.json"),
+ ),
+ ],
+)
+def test_fail_schema_validation(app: ApiGatewayResolver, event, validation_schema):
+ @app.post("/my/path", middlewares=[SchemaValidationMiddleware(validation_schema)])
+ def post_lambda() -> Response:
+ return Response(200, content_types.TEXT_HTML, "Should not be returned")
+
+ # WHEN calling the event handler
+ result = app(event, {})
+ print(f"\nRESULT:::{result}")
+
+ # THEN
+ assert result["statusCode"] == 400
+ assert (
+ result["body"]
+ == "{\"statusCode\":400,\"message\":\"Bad Request: Failed schema validation. Error: data must contain ['message'] properties, Path: ['data'], Data: {'username': 'lessa'}\"}" # noqa: E501
+ )
+
+
+@pytest.mark.parametrize(
+ "app, event",
+ [
+ (
+ ApiGatewayResolver(proxy_type=ProxyEventType.APIGatewayProxyEvent),
+ load_event("apigatewayeSchemaMiddlwareValidEvent.json"),
+ ),
+ (
+ APIGatewayRestResolver(),
+ load_event("apigatewayeSchemaMiddlwareValidEvent.json"),
+ ),
+ (
+ APIGatewayHttpResolver(),
+ load_event("apiGatewayProxyV2SchemaMiddlwareInvalidEvent.json"),
+ ),
+ ],
+)
+def test_invalid_schema_validation(app: ApiGatewayResolver, event):
+ @app.post("/my/path", middlewares=[SchemaValidationMiddleware(inbound_schema="schema.json")])
+ def post_lambda() -> Response:
+ return Response(200, content_types.TEXT_HTML, "Should not be returned")
+
+ # WHEN calling the event handler
+ result = app(event, {})
+
+ print(f"\nRESULT:::{result}")
+ # THEN
+ assert result["statusCode"] == 500
+ assert result["body"] == '{"statusCode":500,"message":"Internal Server Error"}'
+
+
+@pytest.mark.parametrize(
+ "app, event",
+ [
+ (ApiGatewayResolver(proxy_type=ProxyEventType.APIGatewayProxyEvent), API_REST_EVENT),
+ (APIGatewayRestResolver(), API_REST_EVENT),
+ (APIGatewayHttpResolver(), API_RESTV2_EVENT),
+ ],
+)
+def test_middleware_short_circuit_via_httperrors(app: ApiGatewayResolver, event):
+ def middleware_one(app: ApiGatewayResolver, next_middleware: NextMiddleware):
+ # inject a variable into the kwargs of the middleware chain
+ app.append_context(injected="injected_value")
+ response = next_middleware(app)
+
+ return response
+
+ def early_return_middleware(app: ApiGatewayResolver, next_middleware: NextMiddleware):
+ # ensure "injected" context variable is passed in by middleware_one
+ assert app.context.get("injected") == "injected_value"
+ raise BadRequestError("bad_response")
+
+ def not_executed_middleware(app: ApiGatewayResolver, next_middleware: NextMiddleware):
+ # This should never be executed - if it is an excpetion will be raised
+ raise NotImplementedError()
+
+ @app.get("//", middlewares=[middleware_one, early_return_middleware, not_executed_middleware])
+ def get_lambda(my_id: str, name: str) -> Response:
+ assert name == "my"
+ assert app.context.get("injected") == "injected_value"
+
+ return Response(200, content_types.TEXT_HTML, my_id)
+
+ # WHEN calling the event handler
+ result = app(event, {})
+
+ # THEN
+ assert result["statusCode"] == 400
+ assert result["body"] == '{"statusCode":400,"message":"bad_response"}'
+
+
+@pytest.mark.parametrize(
+ "app, event",
+ [
+ (ApiGatewayResolver(proxy_type=ProxyEventType.APIGatewayProxyEvent), API_REST_EVENT),
+ (APIGatewayRestResolver(), API_REST_EVENT),
+ (APIGatewayHttpResolver(), API_RESTV2_EVENT),
+ ],
+)
+def test_api_gateway_middleware_order_with_include_router_last(app: EventHandlerInstance, event):
+ # GIVEN two global middlewares: one for App and one for Router
+ router = Router()
+
+ def global_app_middleware(app: EventHandlerInstance, next_middleware: NextMiddleware):
+ middleware_order: list[str] = router.context.get("middleware_order", [])
+ middleware_order.append("app")
+
+ app.append_context(middleware_order=middleware_order)
+ return next_middleware(app)
+
+ def global_router_middleware(router: EventHandlerInstance, next_middleware: NextMiddleware):
+ middleware_order: list[str] = router.context.get("middleware_order", [])
+ middleware_order.append("router")
+
+ router.append_context(middleware_order=middleware_order)
+ return next_middleware(app)
+
+ @router.get("/my/path")
+ def dummy_route():
+ middleware_order = app.context["middleware_order"]
+
+ assert middleware_order[0] == "app"
+ assert middleware_order[1] == "router"
+
+ return Response(status_code=200, body="works!")
+
+ # WHEN App global middlewares are registered first
+ # followed by include_router
+
+ router.use([global_router_middleware]) # mimics App importing Router
+ app.use([global_app_middleware])
+ app.include_router(router)
+
+ # THEN resolving a request should start processing global Router middlewares first
+ # due to insertion order
+ result = app(event, {})
+
+ assert result["statusCode"] == 200
+
+
+def test_api_gateway_middleware_with_include_router_prefix():
+ # GIVEN an App and Router instance
+ app = ApiGatewayResolver()
+ router = Router()
+
+ def app_middleware(app: EventHandlerInstance, next_middleware: NextMiddleware):
+ # AND a variable injected into resolver context
+ app.append_context(injected="injected_value")
+ return next_middleware(app)
+
+ # WHEN we register a route with a middleware
+ @router.get("/path", middlewares=[app_middleware])
+ def dummy_route():
+ # THEN we should have access to the middleware's injected variable
+ assert app.context["injected"] == "injected_value"
+
+ return Response(status_code=200, body="works!")
+
+ # WHEN register the route with a prefix
+ app.include_router(router, prefix="/my")
+
+ # THEN resolving a request must execute the middleware
+ # and return a successful response http 200 status code
+ result = app(API_REST_EVENT, {})
+
+ assert result["statusCode"] == 200
+
+
+@pytest.mark.parametrize(
+ "app, event",
+ [
+ (ApiGatewayResolver(proxy_type=ProxyEventType.APIGatewayProxyEvent), API_REST_EVENT),
+ (APIGatewayRestResolver(), API_REST_EVENT),
+ (APIGatewayHttpResolver(), API_RESTV2_EVENT),
+ ],
+)
+def test_api_gateway_middleware_order_with_include_router_first(app: EventHandlerInstance, event):
+ # GIVEN two global middlewares: one for App and one for Router
+ router = Router()
+
+ def global_app_middleware(app: EventHandlerInstance, next_middleware: NextMiddleware):
+ middleware_order: list[str] = router.context.get("middleware_order", [])
+ middleware_order.append("app")
+
+ app.append_context(middleware_order=middleware_order)
+ return next_middleware(app)
+
+ def global_router_middleware(router: EventHandlerInstance, next_middleware: NextMiddleware):
+ middleware_order: list[str] = router.context.get("middleware_order", [])
+ middleware_order.append("router")
+
+ router.append_context(middleware_order=middleware_order)
+ return next_middleware(app)
+
+ @router.get("/my/path")
+ def dummy_route():
+ middleware_order = app.context["middleware_order"]
+
+ assert middleware_order[0] == "router"
+ assert middleware_order[1] == "app"
+
+ return Response(status_code=200, body="works!")
+
+ # WHEN App include router middlewares first
+ # followed by App global middlewares registration
+
+ router.use([global_router_middleware]) # mimics App importing Router
+ app.include_router(router)
+
+ app.use([global_app_middleware])
+
+ # THEN resolving a request should start processing global Router middlewares first
+ # due to insertion order
+ result = app(event, {})
+
+ assert result["statusCode"] == 200
+
+
+def test_class_based_middleware():
+ # GIVEN a class-based middleware implementing BaseMiddlewareHandler correctly
+ class CorrelationIdMiddleware(BaseMiddlewareHandler):
+ def __init__(self, header: str):
+ super().__init__()
+ self.header = header
+
+ def handler(self, app: ApiGatewayResolver, get_response: NextMiddleware, **kwargs) -> Response:
+ request_id = app.current_event.request_context.request_id # type: ignore[attr-defined] # using REST event in a base Resolver # noqa: E501
+ correlation_id = app.current_event.headers.get(self.header, request_id)
+
+ response = get_response(app, **kwargs)
+ response.headers[self.header] = correlation_id
+
+ return response
+
+ resolver = ApiGatewayResolver()
+ event = load_event("apiGatewayProxyEvent.json")
+
+ # WHEN instantiated with extra configuration as part of a route handler
+ @resolver.get("/my/path", middlewares=[CorrelationIdMiddleware(header="X-Correlation-Id")])
+ def post_lambda():
+ return {"hello": "world"}
+
+ # THEN it should work as any other middleware when a request is processed
+ result = resolver(event, {})
+ assert result["statusCode"] == 200
+ assert result["multiValueHeaders"]["X-Correlation-Id"][0] == resolver.current_event.request_context.request_id # type: ignore[attr-defined] # noqa: E501
+
+
+@pytest.mark.parametrize(
+ "app, event",
+ [
+ (ApiGatewayResolver(proxy_type=ProxyEventType.APIGatewayProxyEvent), API_REST_EVENT),
+ (APIGatewayRestResolver(), API_REST_EVENT),
+ (APIGatewayHttpResolver(), API_RESTV2_EVENT),
+ ],
+)
+def test_global_middleware_not_found(app: ApiGatewayResolver, event):
+ # GIVEN global middleware is registered
+
+ def middleware(app: ApiGatewayResolver, next_middleware: NextMiddleware):
+ # add additional data to Router Context
+ ret = next_middleware(app)
+ ret.body = "middleware works"
+ return ret
+
+ app.use(middlewares=[middleware])
+
+ @app.get("/this/path/does/not/exist")
+ def nope() -> dict: ...
+
+ # WHEN calling the event handler for an unregistered route /my/path
+ result = app(event, {})
+
+ # THEN process event correctly as HTTP 404
+ # AND ensure middlewares are called
+ assert result["statusCode"] == 404
+ assert result["body"] == "middleware works"
+
+
+def test_global_middleware_not_found_preflight():
+ # GIVEN global middleware is registered
+
+ app = ApiGatewayResolver(cors=CORSConfig(), proxy_type=ProxyEventType.APIGatewayProxyEvent)
+ event = {**API_REST_EVENT, "httpMethod": "OPTIONS"}
+
+ def middleware(app: ApiGatewayResolver, next_middleware: NextMiddleware):
+ # add additional data to Router Context
+ ret = next_middleware(app)
+ ret.body = "middleware works"
+ return ret
+
+ app.use(middlewares=[middleware])
+
+ @app.get("/this/path/does/not/exist")
+ def nope() -> dict: ...
+
+ # WHEN calling the event handler for an unregistered route /my/path OPTIONS
+ result = app(event, {})
+
+ # THEN process event correctly as HTTP 204 (not 404)
+ # AND ensure middlewares are called
+ assert result["statusCode"] == 204
+ assert result["body"] == "middleware works"
diff --git a/tests/functional/event_handler/required_dependencies/test_async_middleware_frame.py b/tests/functional/event_handler/required_dependencies/test_async_middleware_frame.py
new file mode 100644
index 00000000000..6154820454d
--- /dev/null
+++ b/tests/functional/event_handler/required_dependencies/test_async_middleware_frame.py
@@ -0,0 +1,89 @@
+import asyncio
+
+import pytest
+
+from aws_lambda_powertools.event_handler import content_types
+from aws_lambda_powertools.event_handler.api_gateway import (
+ ApiGatewayResolver,
+ ProxyEventType,
+ Response,
+)
+from aws_lambda_powertools.event_handler.middlewares import NextMiddleware
+from aws_lambda_powertools.event_handler.middlewares.async_utils import AsyncMiddlewareFrame, wrap_middleware_async
+from tests.functional.utils import load_event
+
+API_REST_EVENT = load_event("apiGatewayProxyEvent.json")
+
+
+def _make_app() -> ApiGatewayResolver:
+ app = ApiGatewayResolver(proxy_type=ProxyEventType.APIGatewayProxyEvent)
+ app.current_event = app._to_proxy_event(API_REST_EVENT)
+ app.lambda_context = {}
+ return app
+
+
+def test_sync_middleware_raising_before_next_does_not_deadlock():
+ # GIVEN a sync middleware that raises before calling next()
+ # This previously caused a deadlock because middleware_called_next was never set
+ app = _make_app()
+
+ class AuthError(Exception):
+ pass
+
+ def failing_middleware(app: ApiGatewayResolver, next_middleware: NextMiddleware):
+ raise AuthError("denied")
+
+ async def next_handler(app: ApiGatewayResolver):
+ await asyncio.sleep(0)
+ return Response(200, content_types.TEXT_HTML, "should not reach")
+
+ frame = AsyncMiddlewareFrame(current_middleware=failing_middleware, next_middleware=next_handler)
+
+ # WHEN calling the frame
+ # THEN the exception propagates without deadlocking
+ with pytest.raises(AuthError, match="denied"):
+ asyncio.run(frame(app))
+
+
+def test_wrap_middleware_async_sync_raising_before_next_does_not_deadlock():
+ # GIVEN a sync middleware that raises before calling next(), using wrap_middleware_async
+ # This exercises _run_sync_middleware_in_thread directly
+ app = _make_app()
+
+ class AuthError(Exception):
+ pass
+
+ def failing_middleware(app, next_middleware):
+ raise AuthError("denied")
+
+ async def next_handler(app):
+ return Response(200, content_types.TEXT_HTML, "should not reach")
+
+ wrapped = wrap_middleware_async(failing_middleware, next_handler)
+
+ # WHEN calling the wrapped middleware
+ # THEN the exception propagates without deadlocking
+ with pytest.raises(AuthError, match="denied"):
+ asyncio.run(wrapped(app))
+
+
+def test_async_middleware_raising_before_next_propagates():
+ # GIVEN an async middleware that raises before calling next()
+ app = _make_app()
+
+ class ValidationError(Exception):
+ pass
+
+ async def failing_middleware(app: ApiGatewayResolver, next_middleware: NextMiddleware):
+ raise ValidationError("invalid request")
+
+ async def next_handler(app: ApiGatewayResolver):
+ await asyncio.sleep(0)
+ return Response(200, content_types.TEXT_HTML, "should not reach")
+
+ frame = AsyncMiddlewareFrame(current_middleware=failing_middleware, next_middleware=next_handler)
+
+ # WHEN calling the frame
+ # THEN the exception propagates
+ with pytest.raises(ValidationError, match="invalid request"):
+ asyncio.run(frame(app))
diff --git a/tests/functional/event_handler/required_dependencies/test_base_path.py b/tests/functional/event_handler/required_dependencies/test_base_path.py
new file mode 100644
index 00000000000..bbb98c0dc46
--- /dev/null
+++ b/tests/functional/event_handler/required_dependencies/test_base_path.py
@@ -0,0 +1,105 @@
+from __future__ import annotations
+
+from aws_lambda_powertools.event_handler import (
+ ALBResolver,
+ APIGatewayHttpResolver,
+ APIGatewayRestResolver,
+ LambdaFunctionUrlResolver,
+ VPCLatticeResolver,
+ VPCLatticeV2Resolver,
+)
+from tests.functional.utils import load_event
+
+
+def test_base_path_api_gateway_rest():
+ app = APIGatewayRestResolver()
+
+ @app.get("/")
+ def handle():
+ return app._get_base_path()
+
+ event = load_event("apiGatewayProxyEvent.json")
+ event["path"] = "/"
+
+ result = app(event, {})
+ assert result["statusCode"] == 200
+ assert result["body"] == ""
+
+
+def test_base_path_api_gateway_http():
+ app = APIGatewayHttpResolver()
+
+ @app.get("/")
+ def handle():
+ return app._get_base_path()
+
+ event = load_event("apiGatewayProxyV2Event.json")
+ event["rawPath"] = "/"
+ event["requestContext"]["http"]["path"] = "/"
+ event["requestContext"]["http"]["method"] = "GET"
+
+ result = app(event, {})
+ assert result["statusCode"] == 200
+ assert result["body"] == ""
+
+
+def test_base_path_alb():
+ app = ALBResolver()
+
+ @app.get("/")
+ def handle():
+ return app._get_base_path()
+
+ event = load_event("albEvent.json")
+ event["path"] = "/"
+
+ result = app(event, {})
+ assert result["statusCode"] == 200
+ assert result["body"] == ""
+
+
+def test_base_path_lambda_function_url():
+ app = LambdaFunctionUrlResolver()
+
+ @app.get("/")
+ def handle():
+ return app._get_base_path()
+
+ event = load_event("lambdaFunctionUrlIAMEvent.json")
+ event["rawPath"] = "/"
+ event["requestContext"]["http"]["path"] = "/"
+ event["requestContext"]["http"]["method"] = "GET"
+
+ result = app(event, {})
+ assert result["statusCode"] == 200
+ assert result["body"] == ""
+
+
+def test_vpc_lattice():
+ app = VPCLatticeResolver()
+
+ @app.get("/")
+ def handle():
+ return app._get_base_path()
+
+ event = load_event("vpcLatticeEvent.json")
+ event["raw_path"] = "/"
+
+ result = app(event, {})
+ assert result["statusCode"] == 200
+ assert result["body"] == ""
+
+
+def test_vpc_latticev2():
+ app = VPCLatticeV2Resolver()
+
+ @app.get("/")
+ def handle():
+ return app._get_base_path()
+
+ event = load_event("vpcLatticeV2Event.json")
+ event["path"] = "/"
+
+ result = app(event, {})
+ assert result["statusCode"] == 200
+ assert result["body"] == ""
diff --git a/tests/functional/event_handler/required_dependencies/test_bedrock_agent_functions.py b/tests/functional/event_handler/required_dependencies/test_bedrock_agent_functions.py
new file mode 100644
index 00000000000..4719f8df110
--- /dev/null
+++ b/tests/functional/event_handler/required_dependencies/test_bedrock_agent_functions.py
@@ -0,0 +1,455 @@
+from __future__ import annotations
+
+import decimal
+import json
+
+import pytest
+
+from aws_lambda_powertools.event_handler import BedrockAgentFunctionResolver, BedrockFunctionResponse
+from aws_lambda_powertools.utilities.data_classes import BedrockAgentFunctionEvent
+from aws_lambda_powertools.warnings import PowertoolsUserWarning
+from tests.functional.utils import load_event
+
+
+class LambdaContext:
+ def __init__(self):
+ self.function_name = "test-func"
+ self.memory_limit_in_mb = 128
+ self.invoked_function_arn = "arn:aws:lambda:eu-west-1:809313241234:function:test-func"
+ self.aws_request_id = "52fdfc07-2182-154f-163f-5f0f9a621d72"
+
+ def get_remaining_time_in_millis(self) -> int:
+ return 1000
+
+
+def test_bedrock_agent_function_with_string_response():
+ # GIVEN a Bedrock Agent Function resolver
+ app = BedrockAgentFunctionResolver()
+
+ @app.tool()
+ def test_function():
+ assert isinstance(app.current_event, BedrockAgentFunctionEvent)
+ return "Hello from string"
+
+ # WHEN calling the event handler
+ raw_event = load_event("bedrockAgentFunctionEvent.json")
+ raw_event["function"] = "test_function"
+ result = app.resolve(raw_event, {})
+
+ # THEN process event correctly with string response
+ assert result["messageVersion"] == "1.0"
+ assert result["response"]["actionGroup"] == raw_event["actionGroup"]
+ assert result["response"]["function"] == "test_function"
+ assert result["response"]["functionResponse"]["responseBody"]["TEXT"]["body"] == json.dumps("Hello from string")
+ assert "responseState" not in result["response"]["functionResponse"]
+
+
+def test_bedrock_agent_function_with_none_response():
+ # GIVEN a Bedrock Agent Function resolver
+ app = BedrockAgentFunctionResolver()
+
+ @app.tool()
+ def none_response_function():
+ return None
+
+ # WHEN calling the event handler with a function returning None
+ raw_event = load_event("bedrockAgentFunctionEvent.json")
+ raw_event["function"] = "none_response_function"
+ result = app.resolve(raw_event, {})
+
+ # THEN process event correctly with empty string body
+ assert result["response"]["functionResponse"]["responseBody"]["TEXT"]["body"] == json.dumps("")
+
+
+def test_bedrock_agent_function_error_handling():
+ # GIVEN a Bedrock Agent Function resolver
+ app = BedrockAgentFunctionResolver()
+
+ @app.tool(description="Function with error handling")
+ def error_function():
+ return BedrockFunctionResponse(
+ body="Invalid input",
+ response_state="REPROMPT",
+ session_attributes={"error": "true"},
+ )
+
+ @app.tool(description="Function that raises error")
+ def exception_function():
+ raise ValueError("Something went wrong")
+
+ # WHEN calling with explicit error response
+ raw_event = load_event("bedrockAgentFunctionEvent.json")
+ raw_event["function"] = "error_function"
+ result = app.resolve(raw_event, {})
+
+ # THEN include REPROMPT state and session attributes
+ assert result["response"]["functionResponse"]["responseState"] == "REPROMPT"
+ assert result["sessionAttributes"] == {"error": "true"}
+
+
+def test_bedrock_agent_function_registration():
+ # GIVEN a Bedrock Agent Function resolver
+ app = BedrockAgentFunctionResolver()
+
+ # WHEN registering with duplicate name
+ @app.tool(name="custom", description="First registration")
+ def first_function():
+ return "first test"
+
+ # THEN a warning should be issued when registering a duplicate
+ with pytest.warns(PowertoolsUserWarning, match="Tool 'custom' already registered"):
+
+ @app.tool(name="custom", description="Second registration")
+ def second_function():
+ return "second test"
+
+ # AND the most recent function should be registered
+ raw_event = load_event("bedrockAgentFunctionEvent.json")
+ raw_event["function"] = "custom"
+ result = app.resolve(raw_event, {})
+
+ # The second function should be used
+ assert result["response"]["functionResponse"]["responseBody"]["TEXT"]["body"] == json.dumps("second test")
+
+
+def test_bedrock_agent_function_with_optional_fields():
+ # GIVEN a Bedrock Agent Function resolver
+ app = BedrockAgentFunctionResolver()
+
+ @app.tool(description="Function with all optional fields")
+ def test_function():
+ return BedrockFunctionResponse(
+ body="Hello",
+ session_attributes={"userId": "123"},
+ prompt_session_attributes={"context": "test"},
+ knowledge_bases=[
+ {
+ "knowledgeBaseId": "kb1",
+ "retrievalConfiguration": {"vectorSearchConfiguration": {"numberOfResults": 5}},
+ },
+ ],
+ )
+
+ # WHEN calling the event handler
+ raw_event = load_event("bedrockAgentFunctionEvent.json")
+ raw_event["function"] = "test_function"
+ result = app.resolve(raw_event, {})
+
+ # THEN include all optional fields in response
+ assert result["response"]["functionResponse"]["responseBody"]["TEXT"]["body"] == json.dumps("Hello")
+ assert result["sessionAttributes"] == {"userId": "123"}
+ assert result["promptSessionAttributes"] == {"context": "test"}
+ assert result["knowledgeBasesConfiguration"][0]["knowledgeBaseId"] == "kb1"
+
+
+def test_bedrock_agent_function_invalid_event():
+ # GIVEN a Bedrock Agent Function resolver
+ app = BedrockAgentFunctionResolver()
+
+ # WHEN calling with invalid event
+ with pytest.raises(ValueError, match="Missing required field"):
+ app.resolve({}, {})
+
+
+def test_resolve_raises_value_error_on_missing_required_field():
+ """Test that resolve() raises ValueError when a required field is missing from the event"""
+ # GIVEN a Bedrock Agent Function resolver and an incomplete event
+ resolver = BedrockAgentFunctionResolver()
+ incomplete_event = {
+ "messageVersion": "1.0",
+ "agent": {"alias": "PROD", "name": "hr-assistant-function-def", "version": "1", "id": "1234abcd"},
+ "sessionId": "123456789123458",
+ }
+
+ # WHEN calling resolve with the incomplete event
+ # THEN a ValueError is raised with information about the missing field
+ with pytest.raises(ValueError) as excinfo:
+ resolver.resolve(incomplete_event, {})
+
+ assert "Missing required field:" in str(excinfo.value)
+
+
+def test_resolve_with_no_registered_function():
+ # GIVEN a Bedrock Agent Function resolver
+ app = BedrockAgentFunctionResolver()
+
+ # AND a valid event but with a non-existent function
+ raw_event = {
+ "messageVersion": "1.0",
+ "agent": {"name": "TestAgent", "id": "test-id", "alias": "test", "version": "1"},
+ "actionGroup": "test_group",
+ "function": "non_existent_function",
+ "parameters": [],
+ }
+
+ # WHEN calling resolve with a non-existent function
+ result = app.resolve(raw_event, {})
+
+ # THEN the response should contain an error message
+ assert "Error: 'non_existent_function'" in result["response"]["functionResponse"]["responseBody"]["TEXT"]["body"]
+
+
+@pytest.mark.parametrize("response_state", ["FAILURE", "REPROMPT", None])
+def test_bedrock_function_valid_response_states(response_state):
+ # GIVEN a valid response state
+ # WHEN creating a BedrockFunctionResponse with that state
+ # THEN no error should be raised
+ BedrockFunctionResponse(body="test", response_state=response_state)
+
+
+def test_bedrock_function_invalid_response_state():
+ # GIVEN an invalid response state
+ invalid_state = "INVALID"
+
+ # WHEN creating a BedrockFunctionResponse with an invalid state
+ # THEN ValueError should be raised with correct message
+ with pytest.raises(ValueError) as exc_info:
+ BedrockFunctionResponse(body="test", response_state=invalid_state)
+
+ # AND error message should mention valid options
+ error_message = str(exc_info.value)
+ assert "responseState must be" in error_message
+ assert "FAILURE" in error_message
+ assert "REPROMPT" in error_message
+
+
+def test_bedrock_agent_function_with_parameters():
+ # GIVEN a Bedrock Agent Function resolver
+ app = BedrockAgentFunctionResolver()
+
+ # Track received parameters
+ received_params = {}
+
+ @app.tool(description="Function that accepts parameters")
+ def vacation_request(start_date, end_date):
+ # Store received parameters for assertion
+ received_params["start_date"] = start_date
+ received_params["end_date"] = end_date
+ return f"Vacation request from {start_date} to {end_date} submitted"
+
+ # WHEN calling the event handler with parameters
+ raw_event = load_event("bedrockAgentFunctionEvent.json")
+ raw_event["function"] = "vacation_request"
+ result = app.resolve(raw_event, {})
+
+ # THEN parameters should be correctly passed to the function
+ assert received_params["start_date"] == "2024-03-15"
+ assert received_params["end_date"] == "2024-03-20"
+ assert (
+ "Vacation request from 2024-03-15 to 2024-03-20 submitted"
+ in result["response"]["functionResponse"]["responseBody"]["TEXT"]["body"]
+ )
+
+
+def test_bedrock_agent_function_preserves_input_session_attributes():
+ # GIVEN a Bedrock Agent Function resolver
+ app = BedrockAgentFunctionResolver()
+
+ @app.tool()
+ def session_check_function():
+ # Validate that session attributes from the event are accessible
+ assert app.current_event.session_attributes.get("existingKey") == "existingValue"
+ return "Session checked"
+
+ # WHEN calling with event that has session attributes but function doesn't return any
+ raw_event = load_event("bedrockAgentFunctionEvent.json")
+ raw_event["function"] = "session_check_function"
+ raw_event["sessionAttributes"] = {"existingKey": "existingValue"}
+ raw_event["promptSessionAttributes"] = {"promptKey": "promptValue"}
+
+ result = app.resolve(raw_event, {})
+
+ # THEN the original session attributes should be preserved in the response
+ assert result["sessionAttributes"] == {"existingKey": "existingValue"}
+ assert result["promptSessionAttributes"] == {"promptKey": "promptValue"}
+
+
+def test_bedrock_agent_function_with_invalid_parameters():
+ # GIVEN a Bedrock Agent Function resolver
+ app = BedrockAgentFunctionResolver()
+
+ @app.tool()
+ def strict_function(required_param):
+ return f"Got {required_param}"
+
+ # WHEN calling with parameters that don't match the function signature
+ raw_event = load_event("bedrockAgentFunctionEvent.json")
+ raw_event["function"] = "strict_function"
+ raw_event["parameters"] = [
+ {"name": "wrongParam", "value": "wrong value"}, # Wrong parameter name
+ ]
+
+ # THEN function should still be called, but with no parameters
+ result = app.resolve(raw_event, {})
+
+ # Function should raise a TypeError due to missing required parameter
+ assert "Error:" in result["response"]["functionResponse"]["responseBody"]["TEXT"]["body"]
+
+
+def test_bedrock_agent_function_with_complex_return_type():
+ # GIVEN a Bedrock Agent Function resolver
+ app = BedrockAgentFunctionResolver()
+
+ @app.tool()
+ def complex_response():
+ # Return a complex type that needs to be converted to string
+ return {"key1": "value1", "key2": 123, "nested": {"inner": "value"}}
+
+ # WHEN calling with a complex return value
+ raw_event = load_event("bedrockAgentFunctionEvent.json")
+ raw_event["function"] = "complex_response"
+ result = app.resolve(raw_event, {})
+
+ # THEN complex object should be converted to string representation
+ response_body = result["response"]["functionResponse"]["responseBody"]["TEXT"]["body"]
+ # Check that it contains the expected string representation
+
+ assert response_body == json.dumps(
+ {"key1": "value1", "key2": 123, "nested": {"inner": "value"}},
+ )
+
+
+def test_bedrock_agent_function_append_context():
+ # GIVEN a Bedrock Agent Function resolver
+ app = BedrockAgentFunctionResolver()
+
+ @app.tool()
+ def first_function():
+ # Function that appends context and checks for its existence
+ assert app.context.get("custom_key") == "custom_value"
+ assert app.context.get("user_id") == "12345"
+ return "First function executed"
+
+ @app.tool()
+ def second_function():
+ # Function that checks context has been cleared
+ assert not hasattr(app.context, "custom_key")
+ assert not hasattr(app.context, "user_id")
+ # Add new context
+ assert app.context.get("new_key") == "new_value"
+ return "Second function executed"
+
+ # WHEN calling the first function
+ raw_event = load_event("bedrockAgentFunctionEvent.json")
+ raw_event["function"] = "first_function"
+ app.append_context(custom_key="custom_value", user_id="12345")
+ first_result = app.resolve(raw_event, LambdaContext())
+
+ # THEN first function should have accessed the context
+ assert "First function executed" in first_result["response"]["functionResponse"]["responseBody"]["TEXT"]["body"]
+
+ # WHEN calling the second function
+ raw_event["function"] = "second_function"
+ app.append_context(new_key="new_value")
+ second_result = app.resolve(raw_event, LambdaContext())
+
+ # THEN second function should have accessed the context and verified it was cleared
+ assert "Second function executed" in second_result["response"]["functionResponse"]["responseBody"]["TEXT"]["body"]
+
+ # After all invocations, context should be empty
+ assert not hasattr(app.context, "new_key")
+
+
+def test_resolve_with_no_current_event():
+ """Test that _resolve() raises ValueError when current_event is None"""
+ # GIVEN a Bedrock Agent Function resolver with no current event
+ app = BedrockAgentFunctionResolver()
+
+ # Deliberately clear the current_event
+ app.current_event = None
+
+ # WHEN calling the internal _resolve method
+ # THEN a ValueError should be raised
+ with pytest.raises(ValueError, match="No event to process"):
+ app._resolve()
+
+
+def test_bedrock_agent_function_with_parameters_casting():
+ # GIVEN a Bedrock Agent Function resolver
+ app = BedrockAgentFunctionResolver()
+
+ @app.tool(description="Function that accepts parameters")
+ def vacation_request(month: int, payment: float, approved: bool):
+ # Store received parameters for assertion
+ assert isinstance(month, int)
+ assert isinstance(payment, float)
+ assert isinstance(approved, bool)
+ return "Vacation request"
+
+ # WHEN calling the event handler with parameters
+ raw_event = load_event("bedrockAgentFunctionEvent.json")
+ raw_event["function"] = "vacation_request"
+ raw_event["parameters"] = [
+ {"name": "month", "value": "3", "type": "integer"},
+ {"name": "payment", "value": "1000.5", "type": "number"},
+ {"name": "approved", "value": False, "type": "boolean"},
+ ]
+ result = app.resolve(raw_event, {})
+
+ # THEN parameters should be correctly passed to the function
+ assert result["response"]["functionResponse"]["responseBody"]["TEXT"]["body"] == json.dumps("Vacation request")
+
+
+def test_bedrock_agent_function_with_parameters_casting_errors():
+ # GIVEN a Bedrock Agent Function resolver
+ app = BedrockAgentFunctionResolver()
+
+ @app.tool(description="Function that handles parameter casting errors")
+ def process_data(id_product: str, quantity: int, price: float, available: bool, items: list):
+ # Check that invalid values maintain their original types
+ assert isinstance(id_product, str)
+ # For invalid integer, the original string should be preserved
+ assert quantity == "invalid_number"
+ # For invalid float, the original string should be preserved
+ assert price == "not_a_price"
+ # For invalid boolean, should evaluate based on Python's bool rules
+ assert isinstance(available, bool)
+ assert not available
+ # Arrays should remain as is
+ assert isinstance(items, list)
+ return "Processed with casting errors handled"
+
+ # WHEN calling the event handler with parameters that cause casting errors
+ raw_event = load_event("bedrockAgentFunctionEvent.json")
+ raw_event["function"] = "process_data"
+ raw_event["parameters"] = [
+ {"name": "id_product", "value": 12345, "type": "string"}, # Integer to string (should work)
+ {"name": "quantity", "value": "invalid_number", "type": "integer"}, # Will cause ValueError
+ {"name": "price", "value": "not_a_price", "type": "number"}, # Will cause ValueError
+ {"name": "available", "value": "invalid_bool", "type": "boolean"}, # Not "true"/"false"
+ {"name": "items", "value": ["item1", "item2"], "type": "array"}, # Array should remain as is
+ ]
+ result = app.resolve(raw_event, {})
+
+ # THEN parameters should be handled properly despite casting errors
+ assert result["response"]["functionResponse"]["responseBody"]["TEXT"]["body"] == json.dumps(
+ "Processed with casting errors handled",
+ )
+
+
+def test_bedrock_agent_function_with_custom_serializer():
+ """Test BedrockAgentFunctionResolver with a custom serializer for non-standard JSON types."""
+
+ def decimal_serializer(obj):
+ if isinstance(obj, decimal.Decimal):
+ return float(obj)
+ raise TypeError(f"Object of type {type(obj)} is not JSON serializable")
+
+ # GIVEN a Bedrock Agent Function resolver with that custom serializer
+ app = BedrockAgentFunctionResolver(serializer=lambda obj: json.dumps(obj, default=decimal_serializer))
+
+ @app.tool()
+ def decimal_response():
+ # Return a response with Decimal type that standard JSON can't serialize
+ return {"price": round(decimal.Decimal("99"))}
+
+ # WHEN calling with a response containing non-standard JSON types
+ raw_event = load_event("bedrockAgentFunctionEvent.json")
+ raw_event["function"] = "decimal_response"
+ result = app.resolve(raw_event, {})
+
+ # THEN non-standard types should be properly serialized
+ response_body = result["response"]["functionResponse"]["responseBody"]["TEXT"]["body"]
+
+ # VERIFY that decimal was converted to float and datetime to ISO string
+ assert response_body == json.dumps({"price": 99})
diff --git a/tests/functional/event_handler/required_dependencies/test_depends.py b/tests/functional/event_handler/required_dependencies/test_depends.py
new file mode 100644
index 00000000000..d5e49e07cdd
--- /dev/null
+++ b/tests/functional/event_handler/required_dependencies/test_depends.py
@@ -0,0 +1,509 @@
+"""Tests for the Depends() dependency injection feature using Annotated."""
+
+import json
+
+import pytest
+from typing_extensions import Annotated
+
+from aws_lambda_powertools.event_handler import APIGatewayHttpResolver
+from aws_lambda_powertools.event_handler.depends import DependencyResolutionError, Depends
+from aws_lambda_powertools.event_handler.request import Request
+from tests.functional.utils import load_event
+
+API_GW_V2_EVENT = load_event("apiGatewayProxyV2Event.json")
+
+
+def test_depends_simple():
+ """A simple dependency is resolved and injected into the handler."""
+ app = APIGatewayHttpResolver()
+
+ def get_greeting() -> str:
+ return "hello"
+
+ @app.post("/my/path")
+ def handler(greeting: Annotated[str, Depends(get_greeting)]):
+ return {"greeting": greeting}
+
+ result = app(API_GW_V2_EVENT, {})
+ assert result["statusCode"] == 200
+ assert json.loads(result["body"]) == {"greeting": "hello"}
+
+
+def test_depends_nested():
+ """Dependencies can depend on other dependencies."""
+ app = APIGatewayHttpResolver()
+
+ def get_prefix() -> str:
+ return "Hello"
+
+ def get_greeting(prefix: Annotated[str, Depends(get_prefix)]) -> str:
+ return f"{prefix}, world!"
+
+ @app.post("/my/path")
+ def handler(greeting: Annotated[str, Depends(get_greeting)]):
+ return {"greeting": greeting}
+
+ result = app(API_GW_V2_EVENT, {})
+ assert result["statusCode"] == 200
+ assert json.loads(result["body"]) == {"greeting": "Hello, world!"}
+
+
+def test_depends_cache_per_invocation():
+ """Same dependency used twice in one invocation is only resolved once (use_cache=True)."""
+ app = APIGatewayHttpResolver()
+ call_count = 0
+
+ def get_config() -> dict:
+ nonlocal call_count
+ call_count += 1
+ return {"key": "value"}
+
+ def get_a(config: Annotated[dict, Depends(get_config)]) -> str:
+ return config["key"]
+
+ def get_b(config: Annotated[dict, Depends(get_config)]) -> str:
+ return config["key"]
+
+ @app.post("/my/path")
+ def handler(a: Annotated[str, Depends(get_a)], b: Annotated[str, Depends(get_b)]):
+ return {"a": a, "b": b}
+
+ result = app(API_GW_V2_EVENT, {})
+ assert result["statusCode"] == 200
+ assert call_count == 1 # get_config called once despite being used by both get_a and get_b
+
+
+def test_depends_no_cache():
+ """use_cache=False resolves every time."""
+ app = APIGatewayHttpResolver()
+ call_count = 0
+
+ def get_value() -> int:
+ nonlocal call_count
+ call_count += 1
+ return call_count
+
+ @app.post("/my/path")
+ def handler(
+ a: Annotated[int, Depends(get_value, use_cache=False)],
+ b: Annotated[int, Depends(get_value, use_cache=False)],
+ ):
+ return {"a": a, "b": b}
+
+ result = app(API_GW_V2_EVENT, {})
+ assert result["statusCode"] == 200
+ assert call_count == 2
+
+
+def test_depends_with_request():
+ """A dependency can receive the Request object."""
+ app = APIGatewayHttpResolver()
+
+ def get_method(request: Request) -> str:
+ return request.method
+
+ @app.post("/my/path")
+ def handler(method: Annotated[str, Depends(get_method)]):
+ return {"method": method}
+
+ result = app(API_GW_V2_EVENT, {})
+ assert result["statusCode"] == 200
+ assert json.loads(result["body"]) == {"method": "POST"}
+
+
+def test_depends_override():
+ """dependency_overrides replaces a dependency callable for testing."""
+ app = APIGatewayHttpResolver()
+
+ def get_tenant() -> str:
+ return "real-tenant"
+
+ @app.post("/my/path")
+ def handler(tenant: Annotated[str, Depends(get_tenant)]):
+ return {"tenant": tenant}
+
+ app.dependency_overrides[get_tenant] = lambda: "test-tenant"
+
+ result = app(API_GW_V2_EVENT, {})
+ assert result["statusCode"] == 200
+ assert json.loads(result["body"]) == {"tenant": "test-tenant"}
+
+ app.dependency_overrides.clear()
+
+
+def test_depends_override_nested():
+ """dependency_overrides works for nested dependencies too."""
+ app = APIGatewayHttpResolver()
+
+ def get_db_client():
+ return "real-db"
+
+ def get_table(db: Annotated[str, Depends(get_db_client)]) -> str:
+ return f"table-from-{db}"
+
+ @app.post("/my/path")
+ def handler(table: Annotated[str, Depends(get_table)]):
+ return {"table": table}
+
+ app.dependency_overrides[get_db_client] = lambda: "mock-db"
+
+ result = app(API_GW_V2_EVENT, {})
+ assert result["statusCode"] == 200
+ assert json.loads(result["body"]) == {"table": "table-from-mock-db"}
+
+ app.dependency_overrides.clear()
+
+
+def test_depends_multiple_handlers():
+ """Dependencies work across different route handlers."""
+ app = APIGatewayHttpResolver()
+
+ def get_user() -> str:
+ return "user-123"
+
+ @app.get("/my/path")
+ def get_handler(user: Annotated[str, Depends(get_user)]):
+ return {"user": user, "action": "get"}
+
+ @app.post("/my/path")
+ def post_handler(user: Annotated[str, Depends(get_user)]):
+ return {"user": user, "action": "post"}
+
+ # Test POST (matches the event)
+ result = app(API_GW_V2_EVENT, {})
+ assert result["statusCode"] == 200
+ assert json.loads(result["body"]) == {"user": "user-123", "action": "post"}
+
+
+def test_depends_reusable_type_alias():
+ """Annotated type aliases can be reused across handlers."""
+ app = APIGatewayHttpResolver()
+
+ def get_tenant() -> str:
+ return "tenant-abc"
+
+ TenantId = Annotated[str, Depends(get_tenant)]
+
+ @app.post("/my/path")
+ def handler(tenant: TenantId):
+ return {"tenant": tenant}
+
+ result = app(API_GW_V2_EVENT, {})
+ assert result["statusCode"] == 200
+ assert json.loads(result["body"]) == {"tenant": "tenant-abc"}
+
+
+def test_handler_without_depends_works_normally():
+ """A plain handler with no Depends() params is not affected by DI."""
+ app = APIGatewayHttpResolver()
+
+ @app.post("/my/path")
+ def handler():
+ return {"ok": True}
+
+ result = app(API_GW_V2_EVENT, {})
+ assert result["statusCode"] == 200
+ assert json.loads(result["body"]) == {"ok": True}
+
+
+def test_depends_not_cached_across_invocations():
+ """Each app() call resolves dependencies fresh — no cross-request leakage."""
+ app = APIGatewayHttpResolver()
+ call_count = 0
+
+ def get_counter() -> int:
+ nonlocal call_count
+ call_count += 1
+ return call_count
+
+ @app.post("/my/path")
+ def handler(c: Annotated[int, Depends(get_counter)]):
+ return {"c": c}
+
+ result1 = app(API_GW_V2_EVENT, {})
+ result2 = app(API_GW_V2_EVENT, {})
+
+ assert json.loads(result1["body"]) == {"c": 1}
+ assert json.loads(result2["body"]) == {"c": 2}
+ assert call_count == 2
+
+
+def test_depends_deeply_nested():
+ """Three-level dependency chain resolves correctly."""
+ app = APIGatewayHttpResolver()
+
+ def get_url() -> str:
+ return "postgres://localhost"
+
+ def get_conn(url: Annotated[str, Depends(get_url)]) -> str:
+ return f"conn({url})"
+
+ def get_session(conn: Annotated[str, Depends(get_conn)]) -> str:
+ return f"session({conn})"
+
+ @app.post("/my/path")
+ def handler(session: Annotated[str, Depends(get_session)]):
+ return {"session": session}
+
+ result = app(API_GW_V2_EVENT, {})
+ assert result["statusCode"] == 200
+ assert json.loads(result["body"]) == {"session": "session(conn(postgres://localhost))"}
+
+
+def test_depends_with_request_reads_headers():
+ """A dependency using Request can read actual request headers."""
+ app = APIGatewayHttpResolver()
+
+ def get_user_agent(request: Request) -> str:
+ return request.headers.get("user-agent", "unknown")
+
+ @app.post("/my/path")
+ def handler(ua: Annotated[str, Depends(get_user_agent)]):
+ return {"ua": ua}
+
+ result = app(API_GW_V2_EVENT, {})
+ assert result["statusCode"] == 200
+ assert isinstance(json.loads(result["body"])["ua"], str)
+
+
+def test_depends_returning_none():
+ """A dependency can return None without breaking."""
+ app = APIGatewayHttpResolver()
+
+ def get_nothing() -> None:
+ return None
+
+ @app.post("/my/path")
+ def handler(val: Annotated[None, Depends(get_nothing)]):
+ return {"val": val}
+
+ result = app(API_GW_V2_EVENT, {})
+ assert result["statusCode"] == 200
+ assert json.loads(result["body"]) == {"val": None}
+
+
+def test_depends_exception_raises_dependency_resolution_error():
+ """If a dependency raises, a DependencyResolutionError wraps the original exception."""
+ app = APIGatewayHttpResolver()
+
+ def broken() -> str:
+ raise ValueError("boom")
+
+ @app.post("/my/path")
+ def handler(val: Annotated[str, Depends(broken)]):
+ return {"val": val}
+
+ with pytest.raises(DependencyResolutionError, match="broken.*boom"):
+ app(API_GW_V2_EVENT, {})
+
+
+def test_depends_non_callable_raises_dependency_resolution_error():
+ """Passing a non-callable to Depends() raises DependencyResolutionError immediately."""
+ with pytest.raises(DependencyResolutionError, match="requires a callable"):
+ Depends("not_a_function") # type: ignore
+
+ with pytest.raises(DependencyResolutionError, match="requires a callable"):
+ Depends(42) # type: ignore
+
+ with pytest.raises(DependencyResolutionError, match="requires a callable"):
+ Depends(None) # type: ignore
+
+
+def test_depends_accepts_lambda():
+ """Depends() works with a lambda as the dependency."""
+ app = APIGatewayHttpResolver()
+
+ @app.post("/my/path")
+ def handler(val: Annotated[str, Depends(lambda: "from-lambda")]):
+ return {"val": val}
+
+ result = app(API_GW_V2_EVENT, {})
+ assert result["statusCode"] == 200
+ assert json.loads(result["body"]) == {"val": "from-lambda"}
+
+
+def test_depends_accepts_class_with_call():
+ """Depends() works with a class that implements __call__."""
+ app = APIGatewayHttpResolver()
+
+ class TenantProvider:
+ def __call__(self) -> str:
+ return "tenant-from-class"
+
+ @app.post("/my/path")
+ def handler(tenant: Annotated[str, Depends(TenantProvider())]):
+ return {"tenant": tenant}
+
+ result = app(API_GW_V2_EVENT, {})
+ assert result["statusCode"] == 200
+ assert json.loads(result["body"]) == {"tenant": "tenant-from-class"}
+
+
+def test_depends_accepts_class_as_factory():
+ """Depends() works with a class itself (constructor as callable)."""
+ app = APIGatewayHttpResolver()
+
+ class Config:
+ def __init__(self):
+ self.region = "us-east-1"
+
+ @app.post("/my/path")
+ def handler(config: Annotated[Config, Depends(Config)]):
+ return {"region": config.region}
+
+ result = app(API_GW_V2_EVENT, {})
+ assert result["statusCode"] == 200
+ assert json.loads(result["body"]) == {"region": "us-east-1"}
+
+
+def test_depends_with_unresolvable_annotations_is_ignored():
+ """A handler whose annotations cannot be resolved by get_type_hints is treated as having no deps."""
+ app = APIGatewayHttpResolver()
+
+ # Build a function with broken annotations that get_type_hints cannot resolve.
+ # The param has a default so the handler can still be called without it.
+ def make_handler():
+ def handler(x: "CompletelyBogusType" = None): # noqa: F821
+ return {"ok": True}
+
+ return handler
+
+ app.post("/my/path")(make_handler())
+
+ result = app(API_GW_V2_EVENT, {})
+ assert result["statusCode"] == 200
+ assert json.loads(result["body"]) == {"ok": True}
+
+
+def test_depends_without_request_does_not_inject():
+ """A dependency that does NOT declare Request still works when request is available."""
+ app = APIGatewayHttpResolver()
+
+ def get_static() -> str:
+ return "no-request-needed"
+
+ @app.post("/my/path")
+ def handler(val: Annotated[str, Depends(get_static)]):
+ return {"val": val}
+
+ result = app(API_GW_V2_EVENT, {})
+ assert result["statusCode"] == 200
+ assert json.loads(result["body"]) == {"val": "no-request-needed"}
+
+
+def test_depends_with_broken_type_hints_on_dependency():
+ """A dependency callable with broken annotations still resolves (get_type_hints fails gracefully)."""
+ app = APIGatewayHttpResolver()
+
+ # Create a callable whose annotations reference a nonexistent type
+ # so get_type_hints() will raise inside solve_dependencies
+ broken_dep = type(
+ "BrokenDep",
+ (),
+ {
+ "__call__": lambda self: "it-works",
+ "__annotations__": {"x": "NonExistentType"},
+ "__module__": __name__,
+ },
+ )()
+
+ @app.post("/my/path")
+ def handler(val: Annotated[str, Depends(broken_dep)]):
+ return {"val": val}
+
+ result = app(API_GW_V2_EVENT, {})
+ assert result["statusCode"] == 200
+ assert json.loads(result["body"]) == {"val": "it-works"}
+
+
+# ---------------------------------------------------------------------------
+# request.context — bridge between middleware and Depends()
+# ---------------------------------------------------------------------------
+
+
+def test_depends_request_context_writable():
+ """Dependencies can write to request.context and handlers can read it."""
+ app = APIGatewayHttpResolver()
+
+ def set_tenant(request: Request) -> str:
+ tenant = request.headers.get("x-tenant-id", "default")
+ request.context["tenant"] = tenant
+ return tenant
+
+ @app.post("/my/path")
+ def handler(tenant: Annotated[str, Depends(set_tenant)], request: Request):
+ return {"tenant": tenant, "from_context": request.context.get("tenant")}
+
+ event = {**API_GW_V2_EVENT, "headers": {**API_GW_V2_EVENT.get("headers", {}), "x-tenant-id": "acme-corp"}}
+ result = app(event, {})
+
+ assert result["statusCode"] == 200
+ body = json.loads(result["body"])
+ assert body["tenant"] == "acme-corp"
+ assert body["from_context"] == "acme-corp"
+
+
+def test_depends_request_context_bridges_middleware():
+ """Middleware writes to app.context, Depends() reads via request.context."""
+ app = APIGatewayHttpResolver()
+
+ def auth_middleware(app, next_middleware):
+ app.append_context(user="admin-user")
+ return next_middleware(app)
+
+ app.use(middlewares=[auth_middleware])
+
+ def get_current_user(request: Request) -> str:
+ return request.context["user"]
+
+ @app.post("/my/path")
+ def handler(user: Annotated[str, Depends(get_current_user)]):
+ return {"user": user}
+
+ result = app(API_GW_V2_EVENT, {})
+ assert result["statusCode"] == 200
+ assert json.loads(result["body"]) == {"user": "admin-user"}
+
+
+def test_depends_request_context_with_router():
+ """request.context works when routes come from an included Router."""
+ from aws_lambda_powertools.event_handler.api_gateway import Router
+
+ app = APIGatewayHttpResolver()
+ router = Router()
+
+ def mw(app, next_middleware):
+ app.append_context(role="admin")
+ return next_middleware(app)
+
+ app.use(middlewares=[mw])
+
+ def get_role(request: Request) -> str:
+ return request.context["role"]
+
+ @router.post("/my/path")
+ def handler(role: Annotated[str, Depends(get_role)]):
+ return {"role": role}
+
+ app.include_router(router)
+
+ result = app(API_GW_V2_EVENT, {})
+ assert result["statusCode"] == 200
+ assert json.loads(result["body"]) == {"role": "admin"}
+
+
+def test_depends_request_resolved_event():
+ """Dependencies can access the full event via request.resolved_event."""
+ app = APIGatewayHttpResolver()
+
+ def get_path(request: Request) -> str:
+ return request.resolved_event.path
+
+ @app.post("/my/path")
+ def handler(path: Annotated[str, Depends(get_path)]):
+ return {"path": path}
+
+ result = app(API_GW_V2_EVENT, {})
+ assert result["statusCode"] == 200
+ body = json.loads(result["body"])
+ assert body["path"] == "/my/path"
diff --git a/tests/functional/event_handler/required_dependencies/test_http_resolver.py b/tests/functional/event_handler/required_dependencies/test_http_resolver.py
new file mode 100644
index 00000000000..4665812e64a
--- /dev/null
+++ b/tests/functional/event_handler/required_dependencies/test_http_resolver.py
@@ -0,0 +1,1456 @@
+"""Tests for HttpResolverLocal - ASGI-compatible HTTP resolver."""
+
+from __future__ import annotations
+
+import asyncio
+import json
+from typing import Any
+
+import pytest
+
+from aws_lambda_powertools.event_handler import HttpResolverLocal, Response
+from aws_lambda_powertools.event_handler.http_resolver import MockLambdaContext
+
+# =============================================================================
+# ASGI Test Helpers
+# =============================================================================
+
+
+def make_asgi_receive(body: bytes = b""):
+ """Create an ASGI receive callable."""
+
+ async def receive() -> dict[str, Any]:
+ await asyncio.sleep(0) # Yield control to satisfy async requirement
+ return {"type": "http.request", "body": body, "more_body": False}
+
+ return receive
+
+
+def make_asgi_send():
+ """Create an ASGI send callable that captures response."""
+ captured: dict[str, Any] = {"status_code": None, "body": b""}
+
+ async def send(message: dict[str, Any]) -> None:
+ await asyncio.sleep(0) # Yield control to satisfy async requirement
+ if message["type"] == "http.response.start":
+ captured["status_code"] = message["status"]
+ elif message["type"] == "http.response.body":
+ captured["body"] = message["body"]
+
+ return send, captured
+
+
+# =============================================================================
+# Basic Routing Tests
+# =============================================================================
+
+
+def test_simple_get_route():
+ # GIVEN a simple GET route
+ app = HttpResolverLocal()
+
+ @app.get("/hello")
+ def hello():
+ return {"message": "Hello, World!"}
+
+ event = {
+ "httpMethod": "GET",
+ "path": "/hello",
+ "headers": {},
+ "queryStringParameters": {},
+ "body": None,
+ }
+
+ # WHEN the route is resolved
+ result = app.resolve(event, MockLambdaContext())
+
+ # THEN it returns 200 with the expected body
+ assert result["statusCode"] == 200
+ body = json.loads(result["body"])
+ assert body["message"] == "Hello, World!"
+
+
+def test_path_parameters():
+ # GIVEN a route with path parameters
+ app = HttpResolverLocal()
+
+ @app.get("/users/")
+ def get_user(user_id: str):
+ return {"user_id": user_id}
+
+ event = {
+ "httpMethod": "GET",
+ "path": "/users/123",
+ "headers": {},
+ "queryStringParameters": {},
+ "pathParameters": {"user_id": "123"},
+ "body": None,
+ }
+
+ # WHEN the route is resolved
+ result = app.resolve(event, MockLambdaContext())
+
+ # THEN it extracts the path parameter correctly
+ assert result["statusCode"] == 200
+ body = json.loads(result["body"])
+ assert body["user_id"] == "123"
+
+
+def test_post_with_body():
+ # GIVEN a POST route that reads the body
+ app = HttpResolverLocal()
+
+ @app.post("/users")
+ def create_user():
+ body = app.current_event.json_body
+ return {"created": True, "name": body["name"]}
+
+ event = {
+ "httpMethod": "POST",
+ "path": "/users",
+ "headers": {"content-type": "application/json"},
+ "queryStringParameters": {},
+ "body": '{"name": "John"}',
+ }
+
+ # WHEN the route is resolved
+ result = app.resolve(event, MockLambdaContext())
+
+ # THEN it parses the JSON body correctly
+ assert result["statusCode"] == 200
+ body = json.loads(result["body"])
+ assert body["created"] is True
+ assert body["name"] == "John"
+
+
+def test_query_parameters():
+ # GIVEN a route that reads query parameters
+ app = HttpResolverLocal()
+
+ @app.get("/search")
+ def search():
+ q = app.current_event.get_query_string_value("q", "")
+ page = app.current_event.get_query_string_value("page", "1")
+ return {"query": q, "page": page}
+
+ event = {
+ "httpMethod": "GET",
+ "path": "/search",
+ "headers": {},
+ "queryStringParameters": {"q": "python", "page": "2"},
+ "multiValueQueryStringParameters": {"q": ["python"], "page": ["2"]},
+ "body": None,
+ }
+
+ # WHEN the route is resolved
+ result = app.resolve(event, MockLambdaContext())
+
+ # THEN it extracts query parameters correctly
+ assert result["statusCode"] == 200
+ body = json.loads(result["body"])
+ assert body["query"] == "python"
+ assert body["page"] == "2"
+
+
+def test_custom_response():
+ # GIVEN a route that returns a custom Response
+ app = HttpResolverLocal()
+
+ @app.get("/custom")
+ def custom():
+ return Response(
+ status_code=201,
+ content_type="application/json",
+ body={"status": "created"},
+ headers={"X-Custom-Header": "value"},
+ )
+
+ event = {
+ "httpMethod": "GET",
+ "path": "/custom",
+ "headers": {},
+ "queryStringParameters": {},
+ "body": None,
+ }
+
+ # WHEN the route is resolved
+ result = app.resolve(event, MockLambdaContext())
+
+ # THEN it returns the custom status code and headers
+ assert result["statusCode"] == 201
+ assert result["headers"]["X-Custom-Header"] == "value"
+
+
+def test_not_found():
+ # GIVEN an app with a defined route
+ app = HttpResolverLocal()
+
+ @app.get("/exists")
+ def exists():
+ return {"exists": True}
+
+ event = {
+ "httpMethod": "GET",
+ "path": "/does-not-exist",
+ "headers": {},
+ "queryStringParameters": {},
+ "body": None,
+ }
+
+ # WHEN requesting an unknown route
+ result = app.resolve(event, MockLambdaContext())
+
+ # THEN it returns 404
+ assert result["statusCode"] == 404
+
+
+def test_custom_not_found_handler():
+ # GIVEN an app with a custom not_found handler
+ app = HttpResolverLocal()
+
+ @app.not_found
+ def custom_not_found(exc: Exception):
+ return Response(
+ status_code=404,
+ content_type="application/json",
+ body={"error": "Custom Not Found", "path": app.current_event.path},
+ )
+
+ @app.get("/exists")
+ def exists():
+ return {"exists": True}
+
+ event = {
+ "httpMethod": "GET",
+ "path": "/unknown-route",
+ "headers": {},
+ "queryStringParameters": {},
+ "body": None,
+ }
+
+ # WHEN requesting an unknown route
+ result = app.resolve(event, MockLambdaContext())
+
+ # THEN it calls the custom handler
+ assert result["statusCode"] == 404
+ body = json.loads(result["body"])
+ assert body["error"] == "Custom Not Found"
+ assert body["path"] == "/unknown-route"
+
+
+# =============================================================================
+# Middleware Tests
+# =============================================================================
+
+
+def test_middleware_execution():
+ # GIVEN an app with middleware
+ app = HttpResolverLocal()
+ middleware_called = []
+
+ def test_middleware(app, next_middleware):
+ middleware_called.append("before")
+ response = next_middleware(app)
+ middleware_called.append("after")
+ return response
+
+ app.use([test_middleware])
+
+ @app.get("/test")
+ def test_route():
+ middleware_called.append("handler")
+ return {"ok": True}
+
+ event = {
+ "httpMethod": "GET",
+ "path": "/test",
+ "headers": {},
+ "queryStringParameters": {},
+ "body": None,
+ }
+
+ # WHEN the route is resolved
+ result = app.resolve(event, MockLambdaContext())
+
+ # THEN middleware executes in correct order
+ assert result["statusCode"] == 200
+ assert middleware_called == ["before", "handler", "after"]
+
+
+def test_middleware_can_short_circuit():
+ # GIVEN an app with auth middleware
+ app = HttpResolverLocal()
+
+ def auth_middleware(app, next_middleware):
+ auth_header = app.current_event.headers.get("authorization")
+ if not auth_header:
+ return Response(status_code=401, body={"error": "Unauthorized"})
+ return next_middleware(app)
+
+ app.use([auth_middleware])
+
+ @app.get("/protected")
+ def protected():
+ return {"secret": "data"}
+
+ # WHEN requesting without auth header
+ event = {
+ "httpMethod": "GET",
+ "path": "/protected",
+ "headers": {},
+ "queryStringParameters": {},
+ "body": None,
+ }
+ result = app.resolve(event, MockLambdaContext())
+
+ # THEN it returns 401
+ assert result["statusCode"] == 401
+
+ # WHEN requesting with auth header
+ event["headers"] = {"authorization": "Bearer token"}
+ result = app.resolve(event, MockLambdaContext())
+
+ # THEN it returns 200
+ assert result["statusCode"] == 200
+
+
+def test_multiple_middlewares():
+ # GIVEN an app with multiple middlewares
+ app = HttpResolverLocal()
+ order = []
+
+ def middleware_1(app, next_middleware):
+ order.append("m1_before")
+ response = next_middleware(app)
+ order.append("m1_after")
+ return response
+
+ def middleware_2(app, next_middleware):
+ order.append("m2_before")
+ response = next_middleware(app)
+ order.append("m2_after")
+ return response
+
+ app.use([middleware_1, middleware_2])
+
+ @app.get("/test")
+ def test_route():
+ order.append("handler")
+ return {"ok": True}
+
+ event = {
+ "httpMethod": "GET",
+ "path": "/test",
+ "headers": {},
+ "queryStringParameters": {},
+ "body": None,
+ }
+
+ # WHEN the route is resolved
+ app.resolve(event, MockLambdaContext())
+
+ # THEN middlewares execute in correct order (onion model)
+ assert order == ["m1_before", "m2_before", "handler", "m2_after", "m1_after"]
+
+
+def test_route_specific_middleware():
+ # GIVEN an app with route-specific middleware
+ app = HttpResolverLocal()
+ route_middleware_called = []
+
+ def route_middleware(app, next_middleware):
+ route_middleware_called.append("route_middleware")
+ return next_middleware(app)
+
+ @app.get("/with-middleware", middlewares=[route_middleware])
+ def with_middleware():
+ return {"has_middleware": True}
+
+ @app.get("/without-middleware")
+ def without_middleware():
+ return {"has_middleware": False}
+
+ # WHEN requesting route WITH middleware
+ event_with = {
+ "httpMethod": "GET",
+ "path": "/with-middleware",
+ "headers": {},
+ "queryStringParameters": {},
+ "body": None,
+ }
+ result = app.resolve(event_with, MockLambdaContext())
+
+ # THEN middleware is called
+ assert result["statusCode"] == 200
+ assert route_middleware_called == ["route_middleware"]
+
+ # WHEN requesting route WITHOUT middleware
+ route_middleware_called.clear()
+ event_without = {
+ "httpMethod": "GET",
+ "path": "/without-middleware",
+ "headers": {},
+ "queryStringParameters": {},
+ "body": None,
+ }
+ result = app.resolve(event_without, MockLambdaContext())
+
+ # THEN middleware is NOT called
+ assert result["statusCode"] == 200
+ assert route_middleware_called == []
+
+
+def test_route_middleware_with_global_middleware():
+ # GIVEN an app with both global and route-specific middleware
+ app = HttpResolverLocal()
+ order = []
+
+ def global_middleware(app, next_middleware):
+ order.append("global_before")
+ response = next_middleware(app)
+ order.append("global_after")
+ return response
+
+ def route_middleware(app, next_middleware):
+ order.append("route_before")
+ response = next_middleware(app)
+ order.append("route_after")
+ return response
+
+ app.use([global_middleware])
+
+ @app.get("/test", middlewares=[route_middleware])
+ def test_route():
+ order.append("handler")
+ return {"ok": True}
+
+ event = {
+ "httpMethod": "GET",
+ "path": "/test",
+ "headers": {},
+ "queryStringParameters": {},
+ "body": None,
+ }
+
+ # WHEN the route is resolved
+ app.resolve(event, MockLambdaContext())
+
+ # THEN global middleware runs first, then route middleware
+ assert order == ["global_before", "route_before", "handler", "route_after", "global_after"]
+
+
+def test_route_middleware_can_modify_response():
+ # GIVEN an app with middleware that modifies response
+ app = HttpResolverLocal()
+
+ def add_header_middleware(app, next_middleware):
+ response = next_middleware(app)
+ response.headers["X-Custom-Header"] = "added-by-middleware"
+ return response
+
+ @app.get("/test", middlewares=[add_header_middleware])
+ def test_route():
+ return {"ok": True}
+
+ event = {
+ "httpMethod": "GET",
+ "path": "/test",
+ "headers": {},
+ "queryStringParameters": {},
+ "body": None,
+ }
+
+ # WHEN the route is resolved
+ result = app.resolve(event, MockLambdaContext())
+
+ # THEN the response has the added header
+ assert result["statusCode"] == 200
+ assert result["headers"]["X-Custom-Header"] == "added-by-middleware"
+
+
+# =============================================================================
+# ASGI Tests
+# =============================================================================
+
+
+@pytest.mark.asyncio
+async def test_asgi_get_request():
+ # GIVEN an app with a GET route
+ app = HttpResolverLocal()
+
+ @app.get("/hello/")
+ def hello(name: str):
+ return {"message": f"Hello, {name}!"}
+
+ scope = {
+ "type": "http",
+ "method": "GET",
+ "path": "/hello/World",
+ "query_string": b"",
+ "headers": [],
+ }
+
+ receive = make_asgi_receive()
+ send, captured = make_asgi_send()
+
+ # WHEN called via ASGI interface
+ await app(scope, receive, send)
+
+ # THEN it returns the expected response
+ assert captured["status_code"] == 200
+ body = json.loads(captured["body"])
+ assert body["message"] == "Hello, World!"
+
+
+@pytest.mark.asyncio
+async def test_asgi_custom_not_found():
+ # GIVEN an app with custom not_found handler
+ app = HttpResolverLocal()
+
+ @app.not_found
+ def custom_not_found(exc: Exception):
+ return Response(
+ status_code=404,
+ content_type="application/json",
+ body={"error": "Custom 404", "path": app.current_event.path},
+ )
+
+ @app.get("/exists")
+ def exists():
+ return {"exists": True}
+
+ scope = {
+ "type": "http",
+ "method": "GET",
+ "path": "/unknown-asgi-route",
+ "query_string": b"",
+ "headers": [],
+ }
+
+ receive = make_asgi_receive()
+ send, captured = make_asgi_send()
+
+ # WHEN requesting unknown route via ASGI
+ await app(scope, receive, send)
+
+ # THEN custom handler is called
+ assert captured["status_code"] == 404
+ body = json.loads(captured["body"])
+ assert body["error"] == "Custom 404"
+ assert body["path"] == "/unknown-asgi-route"
+
+
+@pytest.mark.asyncio
+async def test_asgi_post_request():
+ # GIVEN an app with a POST route
+ app = HttpResolverLocal()
+
+ @app.post("/users")
+ def create_user():
+ body = app.current_event.json_body
+ return {"created": True, "name": body["name"]}
+
+ scope = {
+ "type": "http",
+ "method": "POST",
+ "path": "/users",
+ "query_string": b"",
+ "headers": [(b"content-type", b"application/json")],
+ }
+
+ receive = make_asgi_receive(b'{"name": "John"}')
+ send, captured = make_asgi_send()
+
+ # WHEN called via ASGI interface
+ await app(scope, receive, send)
+
+ # THEN it parses the body correctly
+ assert captured["status_code"] == 200
+ body = json.loads(captured["body"])
+ assert body["created"] is True
+ assert body["name"] == "John"
+
+
+@pytest.mark.asyncio
+async def test_asgi_query_params():
+ # GIVEN an app with a route that reads query params
+ app = HttpResolverLocal()
+
+ @app.get("/search")
+ def search():
+ q = app.current_event.get_query_string_value("q", "")
+ return {"query": q}
+
+ scope = {
+ "type": "http",
+ "method": "GET",
+ "path": "/search",
+ "query_string": b"q=python",
+ "headers": [],
+ }
+
+ receive = make_asgi_receive()
+ send, captured = make_asgi_send()
+
+ # WHEN called via ASGI interface
+ await app(scope, receive, send)
+
+ # THEN it extracts query params correctly
+ body = json.loads(captured["body"])
+ assert body["query"] == "python"
+
+
+# =============================================================================
+# Async Handler Tests
+# =============================================================================
+
+
+@pytest.mark.asyncio
+async def test_async_handler():
+ # GIVEN an app with an async handler
+ app = HttpResolverLocal()
+
+ @app.get("/async")
+ async def async_handler():
+ await asyncio.sleep(0.001)
+ return {"async": True}
+
+ scope = {
+ "type": "http",
+ "method": "GET",
+ "path": "/async",
+ "query_string": b"",
+ "headers": [],
+ }
+
+ receive = make_asgi_receive()
+ send, captured = make_asgi_send()
+
+ # WHEN called via ASGI interface
+ await app(scope, receive, send)
+
+ # THEN async handler executes correctly
+ assert captured["status_code"] == 200
+ body = json.loads(captured["body"])
+ assert body["async"] is True
+
+
+@pytest.mark.asyncio
+async def test_async_handler_with_path_params():
+ # GIVEN an app with async handler and path params
+ app = HttpResolverLocal()
+
+ @app.get("/users/")
+ async def get_user(user_id: str):
+ await asyncio.sleep(0.001)
+ return {"user_id": user_id, "async": True}
+
+ scope = {
+ "type": "http",
+ "method": "GET",
+ "path": "/users/456",
+ "query_string": b"",
+ "headers": [],
+ }
+
+ receive = make_asgi_receive()
+ send, captured = make_asgi_send()
+
+ # WHEN called via ASGI interface
+ await app(scope, receive, send)
+
+ # THEN path params are extracted correctly
+ body = json.loads(captured["body"])
+ assert body["user_id"] == "456"
+ assert body["async"] is True
+
+
+@pytest.mark.asyncio
+async def test_sync_handler_in_async_context():
+ # GIVEN an app with a sync handler
+ app = HttpResolverLocal()
+
+ @app.get("/sync")
+ def sync_handler():
+ return {"sync": True}
+
+ scope = {
+ "type": "http",
+ "method": "GET",
+ "path": "/sync",
+ "query_string": b"",
+ "headers": [],
+ }
+
+ receive = make_asgi_receive()
+ send, captured = make_asgi_send()
+
+ # WHEN called via ASGI interface
+ await app(scope, receive, send)
+
+ # THEN sync handler works in async context
+ body = json.loads(captured["body"])
+ assert body["sync"] is True
+
+
+@pytest.mark.asyncio
+async def test_mixed_sync_async_handlers():
+ # GIVEN an app with both sync and async handlers
+ app = HttpResolverLocal()
+
+ @app.get("/sync")
+ def sync_handler():
+ return {"type": "sync"}
+
+ @app.get("/async")
+ async def async_handler():
+ await asyncio.sleep(0.001)
+ return {"type": "async"}
+
+ receive = make_asgi_receive()
+
+ # WHEN calling sync handler
+ send_sync, captured_sync = make_asgi_send()
+ await app(
+ {"type": "http", "method": "GET", "path": "/sync", "query_string": b"", "headers": []},
+ receive,
+ send_sync,
+ )
+
+ # WHEN calling async handler
+ send_async, captured_async = make_asgi_send()
+ await app(
+ {"type": "http", "method": "GET", "path": "/async", "query_string": b"", "headers": []},
+ receive,
+ send_async,
+ )
+
+ # THEN both work correctly
+ assert json.loads(captured_sync["body"])["type"] == "sync"
+ assert json.loads(captured_async["body"])["type"] == "async"
+
+
+# =============================================================================
+# Exception Handler Tests
+# =============================================================================
+
+
+def test_exception_handler():
+ # GIVEN an app with a custom exception handler
+ app = HttpResolverLocal()
+
+ class CustomError(Exception):
+ pass
+
+ @app.exception_handler(CustomError)
+ def handle_custom_error(exc: CustomError):
+ return Response(
+ status_code=400,
+ content_type="application/json",
+ body={"error": "Custom error handled"},
+ )
+
+ @app.get("/error")
+ def raise_error():
+ raise CustomError("Something went wrong")
+
+ event = {
+ "httpMethod": "GET",
+ "path": "/error",
+ "headers": {},
+ "queryStringParameters": {},
+ "body": None,
+ }
+
+ # WHEN the route raises the exception
+ result = app.resolve(event, MockLambdaContext())
+
+ # THEN the custom handler catches it
+ assert result["statusCode"] == 400
+ body = json.loads(result["body"])
+ assert body["error"] == "Custom error handled"
+
+
+@pytest.mark.asyncio
+async def test_async_exception_handler():
+ # GIVEN an app with exception handler and async route
+ app = HttpResolverLocal()
+
+ class CustomError(Exception):
+ pass
+
+ @app.exception_handler(CustomError)
+ def handle_custom_error(exc: CustomError):
+ return Response(
+ status_code=400,
+ content_type="application/json",
+ body={"error": "Async error handled"},
+ )
+
+ @app.get("/error")
+ async def raise_error():
+ await asyncio.sleep(0.001)
+ raise CustomError("Async error")
+
+ scope = {
+ "type": "http",
+ "method": "GET",
+ "path": "/error",
+ "query_string": b"",
+ "headers": [],
+ }
+
+ receive = make_asgi_receive()
+ send, captured = make_asgi_send()
+
+ # WHEN the async route raises the exception
+ await app(scope, receive, send)
+
+ # THEN the exception handler catches it
+ assert captured["status_code"] == 400
+ body = json.loads(captured["body"])
+ assert body["error"] == "Async error handled"
+
+
+# =============================================================================
+# ASGI Lifespan Tests
+# =============================================================================
+
+
+@pytest.mark.asyncio
+async def test_asgi_lifespan_startup_shutdown():
+ # GIVEN an app
+ app = HttpResolverLocal()
+
+ @app.get("/hello")
+ def hello():
+ return {"message": "Hello"}
+
+ scope = {"type": "lifespan"}
+ messages_received: list[str] = []
+ messages_sent: list[str] = []
+
+ async def receive() -> dict[str, Any]:
+ await asyncio.sleep(0)
+ if not messages_received:
+ messages_received.append("startup")
+ return {"type": "lifespan.startup"}
+ else:
+ messages_received.append("shutdown")
+ return {"type": "lifespan.shutdown"}
+
+ async def send(message: dict[str, Any]) -> None:
+ await asyncio.sleep(0)
+ messages_sent.append(message["type"])
+
+ # WHEN handling lifespan events
+ await app(scope, receive, send)
+
+ # THEN startup and shutdown are handled
+ assert "lifespan.startup.complete" in messages_sent
+ assert "lifespan.shutdown.complete" in messages_sent
+
+
+@pytest.mark.asyncio
+async def test_asgi_ignores_non_http_scope():
+ # GIVEN an app
+ app = HttpResolverLocal()
+
+ @app.get("/hello")
+ def hello():
+ return {"message": "Hello"}
+
+ scope = {"type": "websocket"} # Not HTTP
+ send_called = False
+
+ async def receive() -> dict[str, Any]:
+ await asyncio.sleep(0)
+ return {"type": "websocket.connect"}
+
+ async def send(message: dict[str, Any]) -> None:
+ nonlocal send_called
+ await asyncio.sleep(0)
+ send_called = True
+
+ # WHEN handling non-HTTP scope
+ await app(scope, receive, send)
+
+ # THEN nothing is sent (early return)
+ assert send_called is False
+
+
+@pytest.mark.asyncio
+async def test_asgi_binary_response():
+ # GIVEN an app that returns binary data (bytes body is auto base64 encoded)
+ app = HttpResolverLocal()
+ binary_data = b"\x89PNG\r\n\x1a\n\x00\x00\x00" # PNG header bytes
+
+ @app.get("/image")
+ def get_image():
+ # When body is bytes, Response auto base64 encodes it
+ return Response(
+ status_code=200,
+ content_type="image/png",
+ body=binary_data,
+ )
+
+ scope = {
+ "type": "http",
+ "method": "GET",
+ "path": "/image",
+ "query_string": b"",
+ "headers": [],
+ }
+
+ receive = make_asgi_receive()
+ send, captured = make_asgi_send()
+
+ # WHEN called via ASGI interface
+ await app(scope, receive, send)
+
+ # THEN it decodes base64 and returns binary data
+ assert captured["status_code"] == 200
+ assert captured["body"] == binary_data
+
+
+@pytest.mark.asyncio
+async def test_asgi_duplicate_headers():
+ # GIVEN an ASGI request with duplicate headers
+ app = HttpResolverLocal()
+
+ @app.get("/headers")
+ def get_headers():
+ # Return the accept header which has duplicates
+ accept = app.current_event.headers.get("accept", "")
+ return {"accept": accept}
+
+ scope = {
+ "type": "http",
+ "method": "GET",
+ "path": "/headers",
+ "query_string": b"",
+ "headers": [
+ (b"accept", b"text/html"),
+ (b"accept", b"application/json"), # Duplicate header
+ ],
+ }
+
+ receive = make_asgi_receive()
+ send, captured = make_asgi_send()
+
+ # WHEN called via ASGI interface
+ await app(scope, receive, send)
+
+ # THEN duplicate headers are joined with comma
+ assert captured["status_code"] == 200
+ body = json.loads(captured["body"])
+ assert body["accept"] == "text/html, application/json"
+
+
+@pytest.mark.asyncio
+async def test_asgi_with_cookies():
+ # GIVEN an app that sets cookies
+ from aws_lambda_powertools.shared.cookies import Cookie
+
+ app = HttpResolverLocal()
+
+ @app.get("/set-cookie")
+ def set_cookie():
+ cookie = Cookie(name="session", value="abc123")
+ return Response(
+ status_code=200,
+ content_type="application/json",
+ body={"message": "Cookie set"},
+ cookies=[cookie],
+ )
+
+ scope = {
+ "type": "http",
+ "method": "GET",
+ "path": "/set-cookie",
+ "query_string": b"",
+ "headers": [],
+ }
+
+ receive = make_asgi_receive()
+ captured_headers: list[tuple[bytes, bytes]] = []
+
+ async def send(message: dict[str, Any]) -> None:
+ await asyncio.sleep(0)
+ if message["type"] == "http.response.start":
+ captured_headers.extend(message.get("headers", []))
+
+ # WHEN called via ASGI interface
+ await app(scope, receive, send)
+
+ # THEN Set-Cookie header is present
+ cookie_headers = [h for h in captured_headers if h[0] == b"set-cookie"]
+ assert len(cookie_headers) == 1
+ assert b"session=abc123" in cookie_headers[0][1]
+
+
+@pytest.mark.asyncio
+async def test_async_middleware():
+ # GIVEN an app with async middleware
+ app = HttpResolverLocal()
+ order: list[str] = []
+
+ async def async_middleware(app, next_middleware):
+ order.append("async_before")
+ await asyncio.sleep(0.001)
+ response = await next_middleware(app)
+ order.append("async_after")
+ return response
+
+ app.use([async_middleware])
+
+ @app.get("/test")
+ async def test_route():
+ order.append("handler")
+ return {"ok": True}
+
+ scope = {
+ "type": "http",
+ "method": "GET",
+ "path": "/test",
+ "query_string": b"",
+ "headers": [],
+ }
+
+ receive = make_asgi_receive()
+ send, captured = make_asgi_send()
+
+ # WHEN called via ASGI interface
+ await app(scope, receive, send)
+
+ # THEN async middleware executes correctly
+ assert captured["status_code"] == 200
+ assert order == ["async_before", "handler", "async_after"]
+
+
+def test_unhandled_exception_raises():
+ # GIVEN an app without exception handler for ValueError
+ app = HttpResolverLocal()
+
+ @app.get("/error")
+ def raise_error():
+ raise ValueError("Unhandled error")
+
+ event = {
+ "httpMethod": "GET",
+ "path": "/error",
+ "headers": {},
+ "queryStringParameters": {},
+ "body": None,
+ }
+
+ # WHEN the route raises an unhandled exception
+ # THEN it propagates up
+ with pytest.raises(ValueError, match="Unhandled error"):
+ app.resolve(event, MockLambdaContext())
+
+
+def test_default_not_found_without_custom_handler():
+ # GIVEN an app WITHOUT custom not_found handler
+ app = HttpResolverLocal()
+
+ @app.get("/exists")
+ def exists():
+ return {"exists": True}
+
+ event = {
+ "httpMethod": "GET",
+ "path": "/unknown",
+ "headers": {},
+ "queryStringParameters": {},
+ "body": None,
+ }
+
+ # WHEN requesting unknown route
+ result = app.resolve(event, MockLambdaContext())
+
+ # THEN default 404 response is returned
+ assert result["statusCode"] == 404
+ body = json.loads(result["body"])
+ assert body["message"] == "Not found"
+
+
+def test_method_not_matching_continues_search():
+ # GIVEN an app with routes for different methods on same path
+ app = HttpResolverLocal()
+
+ @app.get("/resource")
+ def get_resource():
+ return {"method": "GET"}
+
+ @app.post("/resource")
+ def post_resource():
+ return {"method": "POST"}
+
+ # WHEN requesting with POST
+ event = {
+ "httpMethod": "POST",
+ "path": "/resource",
+ "headers": {},
+ "queryStringParameters": {},
+ "body": None,
+ }
+ result = app.resolve(event, MockLambdaContext())
+
+ # THEN it finds the POST handler (skipping GET)
+ assert result["statusCode"] == 200
+ body = json.loads(result["body"])
+ assert body["method"] == "POST"
+
+
+def test_list_headers_serialization():
+ # GIVEN an app that returns list headers
+ app = HttpResolverLocal()
+
+ @app.get("/multi-header")
+ def multi_header():
+ return Response(
+ status_code=200,
+ content_type="application/json",
+ body={"ok": True},
+ headers={"X-Custom": ["value1", "value2"]},
+ )
+
+ event = {
+ "httpMethod": "GET",
+ "path": "/multi-header",
+ "headers": {},
+ "queryStringParameters": {},
+ "body": None,
+ }
+
+ # WHEN the route is resolved
+ result = app.resolve(event, MockLambdaContext())
+
+ # THEN list headers are joined with comma
+ assert result["statusCode"] == 200
+ assert result["headers"]["X-Custom"] == "value1, value2"
+
+
+def test_string_body_in_event():
+ # GIVEN an event with string body (not bytes)
+ app = HttpResolverLocal()
+
+ @app.post("/echo")
+ def echo():
+ return {"body": app.current_event.body}
+
+ # Body is already a string, not bytes
+ event = {
+ "httpMethod": "POST",
+ "path": "/echo",
+ "headers": {"content-type": "text/plain"},
+ "queryStringParameters": {},
+ "body": "plain text body",
+ }
+
+ # WHEN the route is resolved
+ result = app.resolve(event, MockLambdaContext())
+
+ # THEN string body is handled correctly
+ assert result["statusCode"] == 200
+ body = json.loads(result["body"])
+ assert body["body"] == "plain text body"
+
+
+@pytest.mark.asyncio
+async def test_asgi_default_not_found():
+ # GIVEN an app WITHOUT custom not_found handler
+ app = HttpResolverLocal()
+
+ @app.get("/exists")
+ def exists():
+ return {"exists": True}
+
+ scope = {
+ "type": "http",
+ "method": "GET",
+ "path": "/unknown-route",
+ "query_string": b"",
+ "headers": [],
+ }
+
+ receive = make_asgi_receive()
+ send, captured = make_asgi_send()
+
+ # WHEN requesting unknown route via ASGI
+ await app(scope, receive, send)
+
+ # THEN default 404 is returned
+ assert captured["status_code"] == 404
+ body = json.loads(captured["body"])
+ assert body["message"] == "Not found"
+
+
+@pytest.mark.asyncio
+async def test_asgi_unhandled_exception_raises():
+ # GIVEN an app without exception handler for ValueError
+ app = HttpResolverLocal()
+
+ @app.get("/error")
+ async def raise_error():
+ raise ValueError("Async unhandled error")
+
+ scope = {
+ "type": "http",
+ "method": "GET",
+ "path": "/error",
+ "query_string": b"",
+ "headers": [],
+ }
+
+ receive = make_asgi_receive()
+ send, _ = make_asgi_send()
+
+ # WHEN the route raises an unhandled exception
+ # THEN it propagates up
+ with pytest.raises(ValueError, match="Async unhandled error"):
+ await app(scope, receive, send)
+
+
+@pytest.mark.asyncio
+async def test_asgi_wrong_method_returns_not_found():
+ # GIVEN an app with only a GET route
+ app = HttpResolverLocal()
+
+ @app.get("/hello")
+ def hello():
+ return {"message": "Hello"}
+
+ # WHEN calling with POST method (route exists but method doesn't match)
+ scope = {
+ "type": "http",
+ "method": "POST",
+ "path": "/hello",
+ "query_string": b"",
+ "headers": [],
+ }
+
+ receive = make_asgi_receive()
+ send, captured = make_asgi_send()
+
+ await app(scope, receive, send)
+
+ # THEN it returns 404 (method mismatch is treated as not found)
+ assert captured["status_code"] == 404
+
+
+# =============================================================================
+# CORS Tests (issue #8267)
+# =============================================================================
+
+
+@pytest.mark.asyncio
+async def test_cors_options_preflight_returns_204():
+ # GIVEN an app with CORSConfig and a POST route
+ from aws_lambda_powertools.event_handler.api_gateway import CORSConfig
+
+ app = HttpResolverLocal(cors=CORSConfig(allow_origin="*"))
+
+ @app.post("/items")
+ def create_item():
+ return {"ok": True}
+
+ # WHEN a browser sends a CORS preflight OPTIONS request
+ scope = {
+ "type": "http",
+ "method": "OPTIONS",
+ "path": "/items",
+ "query_string": b"",
+ "headers": [
+ (b"origin", b"http://localhost:3000"),
+ (b"access-control-request-method", b"POST"),
+ ],
+ }
+
+ receive = make_asgi_receive()
+ captured: dict[str, Any] = {"status_code": None, "headers": []}
+
+ async def send(message: dict[str, Any]) -> None:
+ await asyncio.sleep(0)
+ if message["type"] == "http.response.start":
+ captured["status_code"] = message["status"]
+ captured["headers"].extend(message.get("headers", []))
+
+ await app(scope, receive, send)
+
+ # THEN it returns 204 with CORS headers (not 500 or 404)
+ assert captured["status_code"] == 204
+
+ header_names = [name.lower() for name, _ in captured["headers"]]
+ assert b"access-control-allow-origin" in header_names
+ assert b"access-control-allow-methods" in header_names
+
+
+@pytest.mark.asyncio
+async def test_cors_options_preflight_with_exception_handler_does_not_return_500():
+ # GIVEN an app with CORSConfig and a generic exception handler that returns 500
+ import json
+
+ from aws_lambda_powertools.event_handler.api_gateway import CORSConfig
+
+ app = HttpResolverLocal(cors=CORSConfig(allow_origin="*"))
+
+ @app.post("/items")
+ def create_item():
+ return {"ok": True}
+
+ @app.exception_handler(Exception)
+ def handle_server_error(ex: Exception):
+ return Response(
+ status_code=500,
+ content_type="application/json",
+ body=json.dumps({"error": "internal"}),
+ )
+
+ # WHEN a browser sends a CORS preflight OPTIONS request
+ scope = {
+ "type": "http",
+ "method": "OPTIONS",
+ "path": "/items",
+ "query_string": b"",
+ "headers": [
+ (b"origin", b"http://localhost:3000"),
+ (b"access-control-request-method", b"POST"),
+ ],
+ }
+
+ receive = make_asgi_receive()
+ captured: dict[str, Any] = {"status_code": None, "headers": []}
+
+ async def send(message: dict[str, Any]) -> None:
+ await asyncio.sleep(0)
+ if message["type"] == "http.response.start":
+ captured["status_code"] = message["status"]
+ captured["headers"].extend(message.get("headers", []))
+
+ await app(scope, receive, send)
+
+ # THEN the OPTIONS request returns 204, not 500
+ assert captured["status_code"] == 204
+ header_names = [name.lower() for name, _ in captured["headers"]]
+ assert b"access-control-allow-origin" in header_names
+
+
+@pytest.mark.asyncio
+async def test_no_cors_options_returns_404():
+ # GIVEN an app WITHOUT CORSConfig
+ app = HttpResolverLocal()
+
+ @app.post("/items")
+ def create_item():
+ return {"ok": True}
+
+ # WHEN a browser sends an OPTIONS request (no CORS configured)
+ scope = {
+ "type": "http",
+ "method": "OPTIONS",
+ "path": "/items",
+ "query_string": b"",
+ "headers": [],
+ }
+
+ receive = make_asgi_receive()
+ send, captured = make_asgi_send()
+
+ await app(scope, receive, send)
+
+ # THEN it returns 404 (no CORS config, no special handling)
+ assert captured["status_code"] == 404
+
+
+@pytest.mark.asyncio
+async def test_cors_options_includes_allowed_methods_header():
+ # GIVEN an app with CORSConfig and multiple routes
+ from aws_lambda_powertools.event_handler.api_gateway import CORSConfig
+
+ app = HttpResolverLocal(cors=CORSConfig(allow_origin="https://example.com"))
+
+ @app.get("/resource")
+ def get_resource():
+ return {"method": "GET"}
+
+ @app.post("/resource")
+ def post_resource():
+ return {"method": "POST"}
+
+ # WHEN an OPTIONS preflight is sent
+ scope = {
+ "type": "http",
+ "method": "OPTIONS",
+ "path": "/resource",
+ "query_string": b"",
+ "headers": [
+ (b"origin", b"https://example.com"),
+ (b"access-control-request-method", b"GET"),
+ ],
+ }
+
+ receive = make_asgi_receive()
+ captured: dict[str, Any] = {"status_code": None, "headers": []}
+
+ async def send(message: dict[str, Any]) -> None:
+ await asyncio.sleep(0)
+ if message["type"] == "http.response.start":
+ captured["status_code"] = message["status"]
+ captured["headers"].extend(message.get("headers", []))
+
+ await app(scope, receive, send)
+
+ # THEN 204 is returned with Access-Control-Allow-Methods header
+ assert captured["status_code"] == 204
+ allow_methods_headers = [v for name, v in captured["headers"] if name.lower() == b"access-control-allow-methods"]
+ assert len(allow_methods_headers) == 1
+
+
+@pytest.mark.asyncio
+async def test_cors_disallowed_header_not_in_allow_headers():
+ # GIVEN an app with CORSConfig that only allows specific headers
+ from aws_lambda_powertools.event_handler.api_gateway import CORSConfig
+
+ app = HttpResolverLocal(cors=CORSConfig(allow_origin="*", allow_headers=["X-Custom-Allowed"]))
+
+ @app.post("/items")
+ def create_item():
+ return {"ok": True}
+
+ # WHEN a preflight requests an unlisted header
+ scope = {
+ "type": "http",
+ "method": "OPTIONS",
+ "path": "/items",
+ "query_string": b"",
+ "headers": [
+ (b"origin", b"http://localhost:3000"),
+ (b"access-control-request-method", b"POST"),
+ (b"access-control-request-headers", b"X-Not-Allowed"),
+ ],
+ }
+
+ receive = make_asgi_receive()
+ captured: dict[str, Any] = {"status_code": None, "headers": []}
+
+ async def send(message: dict[str, Any]) -> None:
+ await asyncio.sleep(0)
+ if message["type"] == "http.response.start":
+ captured["status_code"] = message["status"]
+ captured["headers"].extend(message.get("headers", []))
+
+ await app(scope, receive, send)
+
+ # THEN the server still returns 204 (browser enforces the rejection, not the server)
+ assert captured["status_code"] == 204
+
+ # AND the unlisted header is absent from Access-Control-Allow-Headers
+ allow_headers_value = next(
+ (v.decode() for name, v in captured["headers"] if name.lower() == b"access-control-allow-headers"),
+ "",
+ )
+ assert "X-Not-Allowed" not in allow_headers_value
+ # AND the explicitly allowed header IS present
+ assert "X-Custom-Allowed" in allow_headers_value
diff --git a/tests/functional/event_handler/test_lambda_function_url.py b/tests/functional/event_handler/required_dependencies/test_lambda_function_url.py
similarity index 50%
rename from tests/functional/event_handler/test_lambda_function_url.py
rename to tests/functional/event_handler/required_dependencies/test_lambda_function_url.py
index 4d4d5c39f35..cdb0abb4d91 100644
--- a/tests/functional/event_handler/test_lambda_function_url.py
+++ b/tests/functional/event_handler/required_dependencies/test_lambda_function_url.py
@@ -1,8 +1,11 @@
+from __future__ import annotations
+
from aws_lambda_powertools.event_handler import (
LambdaFunctionUrlResolver,
Response,
content_types,
)
+from aws_lambda_powertools.shared.cookies import Cookie
from aws_lambda_powertools.utilities.data_classes import LambdaFunctionUrlEvent
from tests.functional.utils import load_event
@@ -25,9 +28,46 @@ def foo():
# AND set the current_event type as LambdaFunctionUrlEvent
assert result["statusCode"] == 200
assert result["headers"]["Content-Type"] == content_types.TEXT_HTML
+ assert "Cookies" not in result["headers"]
assert result["body"] == "foo"
+def test_lambda_function_url_event_path_trailing_slash():
+ # GIVEN a Lambda Function Url type event
+ app = LambdaFunctionUrlResolver()
+
+ @app.post("/my/path")
+ def foo():
+ return Response(200, content_types.TEXT_HTML, "foo")
+
+ # WHEN calling the event handler with an event with a trailing slash
+ result = app(load_event("lambdaFunctionUrlEventPathTrailingSlash.json"), {})
+
+ # THEN return a 404 error
+ assert result["statusCode"] == 404
+ assert result["headers"]["Content-Type"] == content_types.APPLICATION_JSON
+
+
+def test_lambda_function_url_event_with_cookies():
+ # GIVEN a Lambda Function Url type event
+ app = LambdaFunctionUrlResolver()
+ cookie = Cookie(name="CookieMonster", value="MonsterCookie")
+
+ @app.get("/")
+ def foo():
+ assert isinstance(app.current_event, LambdaFunctionUrlEvent)
+ assert app.lambda_context == {}
+ return Response(200, content_types.TEXT_PLAIN, "foo", cookies=[cookie])
+
+ # WHEN calling the event handler
+ result = app(load_event("lambdaFunctionUrlEvent.json"), {})
+
+ # THEN process event correctly
+ # AND set the current_event type as LambdaFunctionUrlEvent
+ assert result["statusCode"] == 200
+ assert result["cookies"] == ["CookieMonster=MonsterCookie; Secure"]
+
+
def test_lambda_function_url_no_matches():
# GIVEN a Lambda Function Url type event
app = LambdaFunctionUrlResolver()
diff --git a/tests/functional/event_handler/required_dependencies/test_registered_api_adapter_async.py b/tests/functional/event_handler/required_dependencies/test_registered_api_adapter_async.py
new file mode 100644
index 00000000000..10d5b4602f0
--- /dev/null
+++ b/tests/functional/event_handler/required_dependencies/test_registered_api_adapter_async.py
@@ -0,0 +1,335 @@
+import asyncio
+import re
+from typing import cast
+
+import pytest
+from typing_extensions import Annotated
+
+from aws_lambda_powertools.event_handler import content_types
+from aws_lambda_powertools.event_handler.api_gateway import (
+ APIGatewayHttpResolver,
+ ApiGatewayResolver,
+ APIGatewayRestResolver,
+ BaseRouter,
+ ProxyEventType,
+ Response,
+ Route,
+)
+from aws_lambda_powertools.event_handler.depends import Depends
+from aws_lambda_powertools.event_handler.middlewares.async_utils import _registered_api_adapter_async
+from aws_lambda_powertools.event_handler.request import Request
+from tests.functional.utils import load_event
+
+API_REST_EVENT = load_event("apiGatewayProxyEvent.json")
+API_RESTV2_EVENT = load_event("apiGatewayProxyV2Event_GET.json")
+
+
+def _setup_resolver_context(app: ApiGatewayResolver, event: dict) -> None:
+ """Populate the resolver context the same way resolve() does, without calling the full chain."""
+ BaseRouter.current_event = app._to_proxy_event(cast(dict, event))
+ BaseRouter.lambda_context = {}
+
+
+@pytest.mark.parametrize(
+ "app, event",
+ [
+ (ApiGatewayResolver(proxy_type=ProxyEventType.APIGatewayProxyEvent), API_REST_EVENT),
+ (APIGatewayRestResolver(), API_REST_EVENT),
+ (APIGatewayHttpResolver(), API_RESTV2_EVENT),
+ ],
+)
+def test_sync_handler_returns_response(app: ApiGatewayResolver, event):
+ # GIVEN a sync route handler
+ @app.get("/my/path")
+ def get_lambda():
+ return Response(200, content_types.TEXT_HTML, "sync response")
+
+ # WHEN resolving the event through the normal chain
+ result = app(event, {})
+
+ # THEN the sync handler is called and returns correctly
+ assert result["statusCode"] == 200
+ assert result["body"] == "sync response"
+
+
+@pytest.mark.parametrize(
+ "app, event",
+ [
+ (ApiGatewayResolver(proxy_type=ProxyEventType.APIGatewayProxyEvent), API_REST_EVENT),
+ (APIGatewayRestResolver(), API_REST_EVENT),
+ (APIGatewayHttpResolver(), API_RESTV2_EVENT),
+ ],
+)
+def test_async_handler_is_awaited(app: ApiGatewayResolver, event):
+ # GIVEN an async route handler registered on the resolver
+ @app.get("/my/path")
+ async def get_lambda():
+ return Response(200, content_types.TEXT_HTML, "async response")
+
+ # WHEN populating context and calling the async adapter directly
+ _setup_resolver_context(app, event)
+ app.append_context(_route_args={})
+
+ result = asyncio.run(
+ _registered_api_adapter_async(app, get_lambda),
+ )
+
+ # THEN the async handler is awaited and returns correctly
+ assert result.status_code == 200
+ assert result.body == "async response"
+
+
+@pytest.mark.parametrize(
+ "app, event",
+ [
+ (ApiGatewayResolver(proxy_type=ProxyEventType.APIGatewayProxyEvent), API_REST_EVENT),
+ (APIGatewayRestResolver(), API_REST_EVENT),
+ (APIGatewayHttpResolver(), API_RESTV2_EVENT),
+ ],
+)
+def test_sync_handler_through_adapter(app: ApiGatewayResolver, event):
+ # GIVEN a sync route handler
+ @app.get("/my/path")
+ def get_lambda():
+ return Response(200, content_types.TEXT_HTML, "sync via adapter")
+
+ # WHEN calling _registered_api_adapter_async with a sync handler
+ _setup_resolver_context(app, event)
+ app.append_context(_route_args={})
+
+ result = asyncio.run(
+ _registered_api_adapter_async(app, get_lambda),
+ )
+
+ # THEN sync handler works through the async adapter without issue
+ assert result.status_code == 200
+ assert result.body == "sync via adapter"
+
+
+@pytest.mark.parametrize(
+ "app, event",
+ [
+ (ApiGatewayResolver(proxy_type=ProxyEventType.APIGatewayProxyEvent), API_REST_EVENT),
+ (APIGatewayRestResolver(), API_REST_EVENT),
+ (APIGatewayHttpResolver(), API_RESTV2_EVENT),
+ ],
+)
+def test_adapter_passes_route_args_to_async_handler(app: ApiGatewayResolver, event):
+ # GIVEN an async handler that expects route arguments
+ async def get_lambda(name: str):
+ return Response(200, content_types.TEXT_HTML, name)
+
+ # WHEN route_args are set in the context
+ _setup_resolver_context(app, event)
+ app.append_context(_route_args={"name": "powertools"})
+
+ result = asyncio.run(
+ _registered_api_adapter_async(app, get_lambda),
+ )
+
+ # THEN the route args are passed to the handler
+ assert result.status_code == 200
+ assert result.body == "powertools"
+
+
+@pytest.mark.parametrize(
+ "app, event",
+ [
+ (ApiGatewayResolver(proxy_type=ProxyEventType.APIGatewayProxyEvent), API_REST_EVENT),
+ (APIGatewayRestResolver(), API_REST_EVENT),
+ (APIGatewayHttpResolver(), API_RESTV2_EVENT),
+ ],
+)
+def test_adapter_passes_route_args_to_sync_handler(app: ApiGatewayResolver, event):
+ # GIVEN a sync handler that expects route arguments
+ def get_lambda(name: str):
+ return Response(200, content_types.TEXT_HTML, name)
+
+ # WHEN route_args are set in the context
+ _setup_resolver_context(app, event)
+ app.append_context(_route_args={"name": "powertools"})
+
+ result = asyncio.run(
+ _registered_api_adapter_async(app, get_lambda),
+ )
+
+ # THEN the route args are passed to the sync handler
+ assert result.status_code == 200
+ assert result.body == "powertools"
+
+
+def test_adapter_converts_dict_response_from_async_handler():
+ # GIVEN an async handler that returns a dict (not a Response object)
+ app = ApiGatewayResolver(proxy_type=ProxyEventType.APIGatewayProxyEvent)
+
+ async def get_lambda():
+ return {"message": "hello"}
+
+ # WHEN calling through the async adapter
+ _setup_resolver_context(app, API_REST_EVENT)
+ app.append_context(_route_args={})
+
+ result = asyncio.run(
+ _registered_api_adapter_async(app, get_lambda),
+ )
+
+ # THEN _to_response normalizes the dict into a Response object
+ assert result.status_code == 200
+ assert result.body is not None
+
+
+def test_adapter_converts_tuple_response_from_async_handler():
+ # GIVEN an async handler that returns a (dict, status_code) tuple
+ app = ApiGatewayResolver(proxy_type=ProxyEventType.APIGatewayProxyEvent)
+
+ async def get_lambda():
+ return {"created": True}, 201
+
+ # WHEN calling through the async adapter
+ _setup_resolver_context(app, API_REST_EVENT)
+ app.append_context(_route_args={})
+
+ result = asyncio.run(
+ _registered_api_adapter_async(app, get_lambda),
+ )
+
+ # THEN _to_response normalizes the tuple into a Response object
+ assert result.status_code == 201
+
+
+def test_adapter_with_no_route_in_context():
+ # GIVEN a handler and no _route in context
+ app = ApiGatewayResolver(proxy_type=ProxyEventType.APIGatewayProxyEvent)
+
+ async def get_lambda():
+ return Response(200, content_types.TEXT_HTML, "no route")
+
+ # WHEN _route is None in context (default)
+ _setup_resolver_context(app, API_REST_EVENT)
+ app.append_context(_route_args={})
+
+ result = asyncio.run(
+ _registered_api_adapter_async(app, get_lambda),
+ )
+
+ # THEN the adapter skips request injection and dependency resolution
+ assert result.status_code == 200
+ assert result.body == "no route"
+
+
+def test_adapter_injects_request_param():
+ # GIVEN an async handler that declares a Request parameter
+ app = APIGatewayHttpResolver()
+
+ async def get_lambda(request: Request):
+ return Response(200, content_types.TEXT_HTML, request.method)
+
+ # WHEN a Route is present in context with request_param_name not yet checked
+ _setup_resolver_context(app, API_RESTV2_EVENT)
+ route = Route(
+ method="GET",
+ path="/my/path",
+ rule=re.compile(r"^/my/path$"),
+ func=get_lambda,
+ cors=False,
+ compress=False,
+ )
+ app.append_context(_route=route, _route_args={})
+
+ result = asyncio.run(
+ _registered_api_adapter_async(app, get_lambda),
+ )
+
+ # THEN the Request object is injected and request_param_name is cached
+ assert result.status_code == 200
+ assert route.request_param_name_checked is True
+ assert route.request_param_name == "request"
+
+
+def test_adapter_uses_cached_request_param_name():
+ # GIVEN a Route where request_param_name was already resolved
+ app = APIGatewayHttpResolver()
+
+ async def get_lambda(req: Request):
+ return Response(200, content_types.TEXT_HTML, req.method)
+
+ _setup_resolver_context(app, API_RESTV2_EVENT)
+ route = Route(
+ method="GET",
+ path="/my/path",
+ rule=re.compile(r"^/my/path$"),
+ func=get_lambda,
+ cors=False,
+ compress=False,
+ )
+ route.request_param_name = "req"
+ route.request_param_name_checked = True
+ app.append_context(_route=route, _route_args={})
+
+ # WHEN calling the adapter a second time (cache hit)
+ result = asyncio.run(
+ _registered_api_adapter_async(app, get_lambda),
+ )
+
+ # THEN it still injects the Request using the cached param name
+ assert result.status_code == 200
+
+
+def test_adapter_resolves_dependencies():
+ # GIVEN an async handler with Depends() parameters
+ app = APIGatewayHttpResolver()
+
+ def get_greeting() -> str:
+ return "hello"
+
+ async def get_lambda(greeting: Annotated[str, Depends(get_greeting)]):
+ return {"greeting": greeting}
+
+ _setup_resolver_context(app, API_RESTV2_EVENT)
+ route = Route(
+ method="GET",
+ path="/my/path",
+ rule=re.compile(r"^/my/path$"),
+ func=get_lambda,
+ cors=False,
+ compress=False,
+ )
+ app.append_context(_route=route, _route_args={})
+
+ # WHEN calling the adapter
+ result = asyncio.run(
+ _registered_api_adapter_async(app, get_lambda),
+ )
+
+ # THEN dependencies are resolved and injected
+ assert result.status_code == 200
+
+
+def test_adapter_resolves_dependencies_with_sync_handler():
+ # GIVEN a sync handler with Depends() parameters
+ app = APIGatewayHttpResolver()
+
+ def get_greeting() -> str:
+ return "hello"
+
+ def get_lambda(greeting: Annotated[str, Depends(get_greeting)]):
+ return {"greeting": greeting}
+
+ _setup_resolver_context(app, API_RESTV2_EVENT)
+ route = Route(
+ method="GET",
+ path="/my/path",
+ rule=re.compile(r"^/my/path$"),
+ func=get_lambda,
+ cors=False,
+ compress=False,
+ )
+ app.append_context(_route=route, _route_args={})
+
+ # WHEN calling the adapter with a sync handler that has dependencies
+ result = asyncio.run(
+ _registered_api_adapter_async(app, get_lambda),
+ )
+
+ # THEN dependencies are resolved and injected for sync handler too
+ assert result.status_code == 200
diff --git a/tests/functional/event_handler/required_dependencies/test_request.py b/tests/functional/event_handler/required_dependencies/test_request.py
new file mode 100644
index 00000000000..b00ae6659ba
--- /dev/null
+++ b/tests/functional/event_handler/required_dependencies/test_request.py
@@ -0,0 +1,669 @@
+"""Tests for the Request object feature (GH #7992).
+
+Covers:
+- ``app.request`` availability in global and route-level middleware
+- ``Request`` type-annotation injection in route handlers
+- ``Request`` properties: route, path_parameters, method, headers, query_parameters, body
+- ``RuntimeError`` when ``app.request`` is accessed outside of resolution
+- Backward compatibility: routes without ``Request`` continue to work unchanged
+- ``APIGatewayHttpResolver`` and ``ALBResolver`` variants
+"""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+import pytest
+
+from aws_lambda_powertools.event_handler import (
+ ALBResolver,
+ APIGatewayHttpResolver,
+ APIGatewayRestResolver,
+ Request,
+ Response,
+)
+from tests.functional.utils import load_event
+
+if TYPE_CHECKING:
+ from aws_lambda_powertools.event_handler.middlewares import NextMiddleware
+
+# ---------------------------------------------------------------------------
+# Shared test events
+# ---------------------------------------------------------------------------
+
+API_REST_EVENT = load_event("apiGatewayProxyEvent.json") # GET /my/path
+API_RESTV2_EVENT = load_event("apiGatewayProxyV2Event_GET.json")
+
+
+def _make_rest_event(path: str, method: str = "GET", path_parameters: dict | None = None, body: str | None = None):
+ """Build a minimal API Gateway REST (v1) proxy event."""
+ return {
+ "httpMethod": method,
+ "path": path,
+ "pathParameters": path_parameters,
+ "queryStringParameters": None,
+ "multiValueQueryStringParameters": None,
+ "headers": {"Content-Type": "application/json", "user-agent": "pytest"},
+ "multiValueHeaders": {},
+ "body": body,
+ "isBase64Encoded": False,
+ "requestContext": {"httpMethod": method, "resourcePath": path},
+ "resource": path,
+ "stageVariables": None,
+ }
+
+
+# ---------------------------------------------------------------------------
+# app.request in global middleware
+# ---------------------------------------------------------------------------
+
+
+def test_request_available_in_global_middleware():
+ app = APIGatewayRestResolver()
+ captured: list[Request] = []
+
+ def capture_middleware(app: APIGatewayRestResolver, next_middleware: NextMiddleware) -> Response:
+ captured.append(app.request)
+ return next_middleware(app)
+
+ app.use(middlewares=[capture_middleware])
+
+ @app.get("/my/path")
+ def handler():
+ return {}
+
+ app(API_REST_EVENT, {})
+
+ assert len(captured) == 1
+ req = captured[0]
+ assert isinstance(req, Request)
+ assert req.route == "/my/path"
+ assert req.method == "GET"
+
+
+def test_request_route_pattern_uses_openapi_format():
+ """route property should use {param} OpenAPI notation, not Powertools notation."""
+ app = APIGatewayRestResolver()
+ captured: list[Request] = []
+
+ def mw(app: APIGatewayRestResolver, next_middleware: NextMiddleware) -> Response:
+ captured.append(app.request)
+ return next_middleware(app)
+
+ app.use(middlewares=[mw])
+
+ @app.get("/applications/")
+ def handler(application_id: str):
+ return {}
+
+ event = _make_rest_event(
+ "/applications/42",
+ path_parameters={"application_id": "42"},
+ )
+ app(event, {})
+
+ assert captured[0].route == "/applications/{application_id}"
+
+
+def test_request_path_parameters_in_middleware():
+ app = APIGatewayRestResolver()
+ captured: list[dict] = []
+
+ def mw(app: APIGatewayRestResolver, next_middleware: NextMiddleware) -> Response:
+ captured.append(app.request.path_parameters)
+ return next_middleware(app)
+
+ app.use(middlewares=[mw])
+
+ @app.get("/applications/")
+ def handler(application_id: str):
+ return {}
+
+ event = _make_rest_event(
+ "/applications/4da715ee",
+ path_parameters={"application_id": "4da715ee"},
+ )
+ app(event, {})
+
+ assert captured == [{"application_id": "4da715ee"}]
+
+
+def test_request_method_in_middleware():
+ app = APIGatewayRestResolver()
+ methods_seen: list[str] = []
+
+ def mw(app: APIGatewayRestResolver, next_middleware: NextMiddleware) -> Response:
+ methods_seen.append(app.request.method)
+ return next_middleware(app)
+
+ app.use(middlewares=[mw])
+
+ @app.put("/items/")
+ def handler(item_id: str):
+ return {}
+
+ event = _make_rest_event("/items/99", method="PUT", path_parameters={"item_id": "99"})
+ app(event, {})
+
+ assert methods_seen == ["PUT"]
+
+
+def test_request_headers_in_middleware():
+ app = APIGatewayRestResolver()
+ headers_seen: list[dict] = []
+
+ def mw(app: APIGatewayRestResolver, next_middleware: NextMiddleware) -> Response:
+ headers_seen.append(app.request.headers)
+ return next_middleware(app)
+
+ app.use(middlewares=[mw])
+
+ @app.get("/my/path")
+ def handler():
+ return {}
+
+ app(API_REST_EVENT, {})
+
+ assert len(headers_seen) == 1
+ # headers is a dict (may have varying casing depending on event source)
+ assert isinstance(headers_seen[0], dict)
+
+
+def test_request_query_parameters_in_middleware():
+ app = APIGatewayRestResolver()
+ captured: list = []
+
+ def mw(app: APIGatewayRestResolver, next_middleware: NextMiddleware) -> Response:
+ captured.append(app.request.query_parameters)
+ return next_middleware(app)
+
+ app.use(middlewares=[mw])
+
+ @app.get("/search")
+ def handler():
+ return {}
+
+ event = _make_rest_event("/search")
+ event["queryStringParameters"] = {"q": "powertools"}
+ app(event, {})
+
+ assert captured == [{"q": "powertools"}]
+
+
+def test_request_body_in_middleware():
+ app = APIGatewayRestResolver()
+ bodies_seen: list = []
+
+ def mw(app: APIGatewayRestResolver, next_middleware: NextMiddleware) -> Response:
+ bodies_seen.append(app.request.body)
+ return next_middleware(app)
+
+ app.use(middlewares=[mw])
+
+ @app.post("/items")
+ def handler():
+ return {}
+
+ event = _make_rest_event("/items", method="POST", body='{"name": "widget"}')
+ event["httpMethod"] = "POST"
+ app(event, {})
+
+ assert bodies_seen == ['{"name": "widget"}']
+
+
+# ---------------------------------------------------------------------------
+# Request injection in route handlers via type annotation
+# ---------------------------------------------------------------------------
+
+
+def test_request_injected_into_handler():
+ app = APIGatewayRestResolver()
+
+ received: list[Request] = []
+
+ @app.get("/my/path")
+ def handler(request: Request):
+ received.append(request)
+ return {}
+
+ app(API_REST_EVENT, {})
+
+ assert len(received) == 1
+ assert isinstance(received[0], Request)
+ assert received[0].route == "/my/path"
+ assert received[0].method == "GET"
+
+
+def test_request_injected_alongside_path_params():
+ app = APIGatewayRestResolver()
+
+ received: list[tuple] = []
+
+ @app.get("/users/")
+ def handler(user_id: str, request: Request):
+ received.append((user_id, request))
+ return {}
+
+ event = _make_rest_event("/users/123", path_parameters={"user_id": "123"})
+ app(event, {})
+
+ assert len(received) == 1
+ user_id, req = received[0]
+ assert user_id == "123"
+ assert isinstance(req, Request)
+ assert req.path_parameters == {"user_id": "123"}
+ assert req.route == "/users/{user_id}"
+
+
+def test_request_injection_parameter_name_is_flexible():
+ """The parameter can be named anything as long as it is annotated as Request."""
+ app = APIGatewayRestResolver()
+
+ received: list[Request] = []
+
+ @app.get("/my/path")
+ def handler(req: Request):
+ received.append(req)
+ return {}
+
+ app(API_REST_EVENT, {})
+
+ assert received[0].route == "/my/path"
+
+
+def test_handler_without_request_annotation_unaffected():
+ """Existing handlers with no Request annotation continue to work identically."""
+ app = APIGatewayRestResolver()
+
+ @app.get("/my/path")
+ def handler():
+ return {"ok": True}
+
+ result = app(API_REST_EVENT, {})
+ assert result["statusCode"] == 200
+
+
+def test_handler_with_path_params_only_unaffected():
+ """Handlers that only use path params continue to work identically."""
+ app = APIGatewayRestResolver()
+
+ @app.get("/users/")
+ def handler(user_id: str):
+ return {"id": user_id}
+
+ event = _make_rest_event("/users/42", path_parameters={"user_id": "42"})
+ result = app(event, {})
+ assert result["statusCode"] == 200
+
+
+# ---------------------------------------------------------------------------
+# Request injection caching (idempotency across multiple calls)
+# ---------------------------------------------------------------------------
+
+
+def test_request_injection_works_across_multiple_invocations():
+ """Injection must work correctly on repeated calls (cached param name must stay valid)."""
+ app = APIGatewayRestResolver()
+ call_count = 0
+
+ @app.get("/counters/")
+ def handler(counter_id: str, request: Request):
+ nonlocal call_count
+ call_count += 1
+ assert request.path_parameters["counter_id"] == counter_id
+ return {}
+
+ for i in range(3):
+ event = _make_rest_event(f"/counters/{i}", path_parameters={"counter_id": str(i)})
+ result = app(event, {})
+ assert result["statusCode"] == 200
+
+ assert call_count == 3
+
+
+# ---------------------------------------------------------------------------
+# RuntimeError when accessed outside of request resolution
+# ---------------------------------------------------------------------------
+
+
+def test_request_raises_before_resolution():
+ app = APIGatewayRestResolver()
+ with pytest.raises(RuntimeError, match="app.request is only available after route resolution"):
+ _ = app.request
+
+
+# ---------------------------------------------------------------------------
+# Route-level middleware also gets app.request
+# ---------------------------------------------------------------------------
+
+
+def test_request_available_in_route_level_middleware():
+ app = APIGatewayRestResolver()
+ captured: list[Request] = []
+
+ def route_mw(app: APIGatewayRestResolver, next_middleware: NextMiddleware) -> Response:
+ captured.append(app.request)
+ return next_middleware(app)
+
+ @app.get("/protected/", middlewares=[route_mw])
+ def handler(resource_id: str):
+ return {}
+
+ event = _make_rest_event("/protected/abc", path_parameters={"resource_id": "abc"})
+ app(event, {})
+
+ assert len(captured) == 1
+ assert captured[0].route == "/protected/{resource_id}"
+ assert captured[0].path_parameters == {"resource_id": "abc"}
+
+
+# ---------------------------------------------------------------------------
+# Other resolver types
+# ---------------------------------------------------------------------------
+
+
+def test_request_available_in_http_resolver_middleware():
+ app = APIGatewayHttpResolver()
+ captured: list[Request] = []
+
+ def mw(app, next_middleware):
+ captured.append(app.request)
+ return next_middleware(app)
+
+ app.use(middlewares=[mw])
+
+ @app.get("/my/path")
+ def handler():
+ return {}
+
+ app(API_RESTV2_EVENT, {})
+
+ assert len(captured) == 1
+ assert captured[0].method == "GET"
+
+
+def test_request_available_in_alb_middleware():
+ alb_event = load_event("albEvent.json")
+ app = ALBResolver()
+ captured: list[Request] = []
+
+ def mw(app, next_middleware):
+ captured.append(app.request)
+ return next_middleware(app)
+
+ app.use(middlewares=[mw])
+
+ # Register a route that matches the ALB event's path
+ path = alb_event.get("path", "/lambda")
+
+ @app.get(path)
+ def handler():
+ return {}
+
+ app(alb_event, {})
+
+ assert len(captured) == 1
+ assert isinstance(captured[0], Request)
+
+
+# ---------------------------------------------------------------------------
+# Router / include_router pattern
+# ---------------------------------------------------------------------------
+
+
+def test_request_available_in_middleware_with_include_router():
+ """app.request must work in middleware when routes come from an included Router."""
+ from aws_lambda_powertools.event_handler.api_gateway import Router
+
+ app = APIGatewayRestResolver()
+ router = Router()
+ captured: list[Request] = []
+
+ def mw(app, next_middleware):
+ captured.append(app.request)
+ return next_middleware(app)
+
+ app.use(middlewares=[mw])
+
+ @router.get("/users/")
+ def get_user(user_id: str):
+ return {"id": user_id}
+
+ app.include_router(router)
+
+ event = _make_rest_event("/users/abc", path_parameters={"user_id": "abc"})
+ result = app(event, {})
+
+ assert result["statusCode"] == 200
+ assert len(captured) == 1
+ assert captured[0].route == "/users/{user_id}"
+ assert captured[0].path_parameters == {"user_id": "abc"}
+
+
+def test_request_injected_in_handler_with_include_router():
+ """Request injection via type annotation must work when routes come from an included Router."""
+ from aws_lambda_powertools.event_handler.api_gateway import Router
+
+ app = APIGatewayRestResolver()
+ router = Router()
+ received: list[Request] = []
+
+ @router.get("/items/")
+ def get_item(item_id: str, request: Request):
+ received.append(request)
+ return {"id": item_id}
+
+ app.include_router(router)
+
+ event = _make_rest_event("/items/xyz", path_parameters={"item_id": "xyz"})
+ result = app(event, {})
+
+ assert result["statusCode"] == 200
+ assert len(received) == 1
+ assert received[0].route == "/items/{item_id}"
+ assert received[0].path_parameters == {"item_id": "xyz"}
+
+
+# ---------------------------------------------------------------------------
+# Proxy+ use case (the original issue scenario)
+# ---------------------------------------------------------------------------
+
+
+def test_request_resolves_path_params_from_proxy_plus_event():
+ """When API GW uses {proxy+}, app.current_event.pathParameters only has 'proxy'.
+ But app.request.path_parameters should have the *resolved* params from Powertools routing."""
+ app = APIGatewayRestResolver()
+ captured: list[Request] = []
+
+ def auth_middleware(app, next_middleware):
+ captured.append(app.request)
+ return next_middleware(app)
+
+ app.use(middlewares=[auth_middleware])
+
+ @app.get("/applications/")
+ def get_application(application_id: str):
+ return {"id": application_id}
+
+ @app.put("/applications/")
+ def put_application(application_id: str):
+ return {"updated": application_id}
+
+ # Simulate a proxy+ event where API GW only knows about {proxy+}
+ event = {
+ "httpMethod": "PUT",
+ "path": "/applications/4da715ee-79d4-4e52-81cb-1ecc464708fb",
+ "pathParameters": {"proxy": "4da715ee-79d4-4e52-81cb-1ecc464708fb"},
+ "queryStringParameters": None,
+ "multiValueQueryStringParameters": None,
+ "headers": {"Content-Type": "application/json"},
+ "multiValueHeaders": {},
+ "body": None,
+ "isBase64Encoded": False,
+ "requestContext": {"httpMethod": "PUT", "resourcePath": "/applications/{proxy+}"},
+ "resource": "/applications/{proxy+}",
+ "stageVariables": None,
+ }
+
+ result = app(event, {})
+
+ assert result["statusCode"] == 200
+ assert len(captured) == 1
+
+ req = captured[0]
+ # Middleware sees the resolved route, NOT the proxy+ pattern
+ assert req.route == "/applications/{application_id}"
+ assert req.path_parameters == {"application_id": "4da715ee-79d4-4e52-81cb-1ecc464708fb"}
+ assert req.method == "PUT"
+
+
+# ---------------------------------------------------------------------------
+# Missing coverage: json_body, query_parameters=None, request caching
+# ---------------------------------------------------------------------------
+
+
+def test_request_json_body_in_middleware():
+ app = APIGatewayRestResolver()
+ bodies_seen: list = []
+
+ def mw(app: APIGatewayRestResolver, next_middleware):
+ bodies_seen.append(app.request.json_body)
+ return next_middleware(app)
+
+ app.use(middlewares=[mw])
+
+ @app.post("/items")
+ def handler():
+ return {}
+
+ event = _make_rest_event("/items", method="POST", body='{"name": "widget"}')
+ app(event, {})
+
+ assert bodies_seen == [{"name": "widget"}]
+
+
+def test_request_query_parameters_empty():
+ """When no query string parameters are present, query_parameters returns empty or None."""
+ app = APIGatewayRestResolver()
+ captured: list = []
+
+ def mw(app: APIGatewayRestResolver, next_middleware):
+ captured.append(app.request.query_parameters)
+ return next_middleware(app)
+
+ app.use(middlewares=[mw])
+
+ @app.get("/my/path")
+ def handler():
+ return {}
+
+ event = _make_rest_event("/my/path")
+ app(event, {})
+
+ # No query params present — should be falsy (empty dict or None depending on event source)
+ assert not captured[0]
+
+
+def test_request_is_cached_across_multiple_accesses():
+ """Accessing app.request multiple times in the same invocation returns the same object."""
+ app = APIGatewayRestResolver()
+ ids_seen: list[int] = []
+
+ def mw(app: APIGatewayRestResolver, next_middleware):
+ ids_seen.append(id(app.request))
+ ids_seen.append(id(app.request))
+ return next_middleware(app)
+
+ app.use(middlewares=[mw])
+
+ @app.get("/my/path")
+ def handler(request: Request):
+ ids_seen.append(id(request))
+ return {}
+
+ app(API_REST_EVENT, {})
+
+ # All accesses should return the same cached instance
+ assert len(ids_seen) == 3
+ assert ids_seen[0] == ids_seen[1] == ids_seen[2]
+
+
+# ---------------------------------------------------------------------------
+# resolved_event — full Powertools proxy event access
+# ---------------------------------------------------------------------------
+
+
+def test_request_resolved_event_exposes_full_event():
+ """resolved_event should return the full BaseProxyEvent with all helpers."""
+ app = APIGatewayRestResolver()
+ captured: list[Request] = []
+
+ def mw(app: APIGatewayRestResolver, next_middleware):
+ captured.append(app.request)
+ return next_middleware(app)
+
+ app.use(middlewares=[mw])
+
+ @app.get("/my/path")
+ def handler():
+ return {}
+
+ app(API_REST_EVENT, {})
+
+ req = captured[0]
+ resolved = req.resolved_event
+
+ # resolved_event should be the same object as app.current_event
+ assert resolved is not None
+ assert resolved.http_method == "GET"
+ # Should have helper methods not available on Request directly
+ assert hasattr(resolved, "get_header_value")
+ assert hasattr(resolved, "get_query_string_value")
+
+
+def test_request_resolved_event_provides_cookies_and_path():
+ """resolved_event gives access to path and properties not on Request."""
+ app = APIGatewayRestResolver()
+ captured: list[Request] = []
+
+ def mw(app: APIGatewayRestResolver, next_middleware):
+ captured.append(app.request)
+ return next_middleware(app)
+
+ app.use(middlewares=[mw])
+
+ @app.get("/items/")
+ def handler(item_id: str):
+ return {}
+
+ event = _make_rest_event("/items/42", path_parameters={"item_id": "42"})
+ app(event, {})
+
+ resolved = captured[0].resolved_event
+ assert resolved.path == "/items/42"
+
+
+# ---------------------------------------------------------------------------
+# context — shared resolver context (app.context)
+# ---------------------------------------------------------------------------
+
+
+def test_request_context_shares_app_context():
+ """request.context should be the same dict as app.context."""
+ app = APIGatewayRestResolver()
+
+ def mw(app: APIGatewayRestResolver, next_middleware):
+ app.append_context(user="test-user")
+ return next_middleware(app)
+
+ app.use(middlewares=[mw])
+
+ @app.get("/my/path")
+ def handler(request: Request):
+ return {"user": request.context.get("user")}
+
+ result = app(API_REST_EVENT, {})
+ assert result["statusCode"] == 200
+ import json
+
+ assert json.loads(result["body"]) == {"user": "test-user"}
diff --git a/tests/functional/event_handler/required_dependencies/test_resolve_async.py b/tests/functional/event_handler/required_dependencies/test_resolve_async.py
new file mode 100644
index 00000000000..e9b12ce2a2d
--- /dev/null
+++ b/tests/functional/event_handler/required_dependencies/test_resolve_async.py
@@ -0,0 +1,565 @@
+import asyncio
+import json
+
+import pytest
+
+from aws_lambda_powertools.event_handler import content_types
+from aws_lambda_powertools.event_handler.api_gateway import (
+ ALBResolver,
+ APIGatewayHttpResolver,
+ ApiGatewayResolver,
+ APIGatewayRestResolver,
+ BaseRouter,
+ CORSConfig,
+ ProxyEventType,
+ Response,
+)
+from aws_lambda_powertools.event_handler.middlewares import NextMiddleware
+from tests.functional.utils import load_event
+
+API_REST_EVENT = load_event("apiGatewayProxyEvent.json")
+API_RESTV2_EVENT = load_event("apiGatewayProxyV2Event_GET.json")
+ALB_EVENT = load_event("albEvent.json")
+
+
+def _setup_app(app, event):
+ BaseRouter.current_event = app._to_proxy_event(event)
+ BaseRouter.lambda_context = {}
+
+
+RESOLVER_IDS = ["ApiGatewayResolver", "APIGatewayRestResolver", "APIGatewayHttpResolver", "ALBResolver"]
+
+
+@pytest.fixture(
+ params=[
+ ("apigw_v1", API_REST_EVENT, "/my/path"),
+ ("apigw_rest", API_REST_EVENT, "/my/path"),
+ ("apigw_v2", API_RESTV2_EVENT, "/my/path"),
+ ("alb", ALB_EVENT, "/lambda"),
+ ],
+ ids=RESOLVER_IDS,
+)
+def resolver_and_event(request):
+ key, event, path = request.param
+ resolvers = {
+ "apigw_v1": ApiGatewayResolver(proxy_type=ProxyEventType.APIGatewayProxyEvent),
+ "apigw_rest": APIGatewayRestResolver(),
+ "apigw_v2": APIGatewayHttpResolver(),
+ "alb": ALBResolver(),
+ }
+ return resolvers[key], event, path
+
+
+class TestResolveAsyncWithAsyncHandlers:
+ def test_async_handler_through_resolve_chain(self, resolver_and_event):
+ # GIVEN an async handler registered on the resolver
+ app, event, path = resolver_and_event
+
+ @app.get(path)
+ async def get_lambda():
+ await asyncio.sleep(0)
+ return Response(200, content_types.TEXT_HTML, "async works")
+
+ # WHEN calling _resolve_async after setting up context
+ _setup_app(app, event)
+ result = asyncio.run(app._resolve_async())
+
+ # THEN the async handler is awaited and returns a ResponseBuilder
+ response = result.build(app.current_event, app._cors)
+ assert response["statusCode"] == 200
+ assert response["body"] == "async works"
+
+ def test_async_handler_returning_dict(self, resolver_and_event):
+ # GIVEN an async handler that returns a dict
+ app, event, path = resolver_and_event
+
+ @app.get(path)
+ async def get_lambda():
+ await asyncio.sleep(0)
+ return {"message": "hello"}
+
+ # WHEN calling _resolve_async
+ _setup_app(app, event)
+ result = asyncio.run(app._resolve_async())
+
+ # THEN the dict is normalized into a Response
+ response = result.build(app.current_event, app._cors)
+ assert response["statusCode"] == 200
+
+ def test_async_handler_returning_tuple(self, resolver_and_event):
+ # GIVEN an async handler that returns a (dict, status_code) tuple
+ app, event, path = resolver_and_event
+
+ @app.get(path)
+ async def get_lambda():
+ await asyncio.sleep(0)
+ return {"created": True}, 201
+
+ # WHEN calling _resolve_async
+ _setup_app(app, event)
+ result = asyncio.run(app._resolve_async())
+
+ # THEN the tuple is normalized with the correct status code
+ response = result.build(app.current_event, app._cors)
+ assert response["statusCode"] == 201
+
+
+class TestResolveAsyncWithSyncHandlers:
+ def test_sync_handler_works_through_async_chain(self, resolver_and_event):
+ # GIVEN a sync handler
+ app, event, path = resolver_and_event
+
+ @app.get(path)
+ def get_lambda():
+ return Response(200, content_types.TEXT_HTML, "sync via async")
+
+ # WHEN calling _resolve_async
+ _setup_app(app, event)
+ result = asyncio.run(app._resolve_async())
+
+ # THEN the sync handler works through the async chain
+ response = result.build(app.current_event, app._cors)
+ assert response["statusCode"] == 200
+ assert response["body"] == "sync via async"
+
+
+class TestResolveAsyncRouteArguments:
+ def test_route_args_passed_to_async_handler(self):
+ # GIVEN an async handler with a path parameter
+ app = APIGatewayHttpResolver()
+
+ @app.get("/my/")
+ async def get_lambda(name: str):
+ await asyncio.sleep(0)
+ return Response(200, content_types.TEXT_HTML, name)
+
+ # WHEN resolving a matching event
+ event = load_event("apiGatewayProxyV2Event_GET.json")
+ event["rawPath"] = "/my/powertools"
+ event["requestContext"]["http"]["path"] = "/my/powertools"
+ _setup_app(app, event)
+ result = asyncio.run(app._resolve_async())
+
+ # THEN route arguments are passed to the handler
+ response = result.build(app.current_event, app._cors)
+ assert response["statusCode"] == 200
+ assert response["body"] == "powertools"
+
+
+class TestResolveAsyncNotFound:
+ def test_not_found_returns_404(self, resolver_and_event):
+ # GIVEN no matching route
+ app, event, _path = resolver_and_event
+
+ @app.get("/other/path")
+ async def get_lambda():
+ await asyncio.sleep(0)
+ return Response(200, content_types.TEXT_HTML, "should not reach")
+
+ # WHEN resolving an event with a non-matching path
+ _setup_app(app, event)
+ result = asyncio.run(app._resolve_async())
+
+ # THEN a 404 response is returned
+ response = result.build(app.current_event, app._cors)
+ assert response["statusCode"] == 404
+
+ def test_custom_not_found_handler(self):
+ # GIVEN a custom not_found handler
+ app = APIGatewayRestResolver()
+
+ @app.not_found
+ def custom_not_found(exc):
+ return Response(404, content_types.APPLICATION_JSON, '{"error": "custom 404"}')
+
+ @app.get("/other")
+ def get_lambda():
+ return Response(200, content_types.TEXT_HTML, "not reached")
+
+ # WHEN resolving with no matching route
+ _setup_app(app, API_REST_EVENT)
+ result = asyncio.run(app._resolve_async())
+
+ # THEN the custom handler is called
+ response = result.build(app.current_event, app._cors)
+ assert response["statusCode"] == 404
+ assert response["body"] == '{"error": "custom 404"}'
+
+ def test_cors_preflight_returns_204(self):
+ # GIVEN a resolver with CORS enabled
+ app = APIGatewayRestResolver(cors=CORSConfig())
+
+ @app.get("/my/path")
+ def get_lambda():
+ return Response(200, content_types.TEXT_HTML, "ok")
+
+ # WHEN an OPTIONS request arrives for a non-matching path
+ event = load_event("apiGatewayProxyEvent.json")
+ event["httpMethod"] = "OPTIONS"
+ _setup_app(app, event)
+ result = asyncio.run(app._resolve_async())
+
+ # THEN a 204 pre-flight response is returned
+ response = result.build(app.current_event, app._cors)
+ assert response["statusCode"] == 204
+
+
+class TestResolveAsyncExceptionHandling:
+ def test_exception_handler_catches_async_error(self):
+ # GIVEN an async handler that raises and an exception handler
+ app = APIGatewayRestResolver()
+
+ @app.exception_handler(ValueError)
+ def handle_value_error(exc):
+ return Response(422, content_types.APPLICATION_JSON, '{"error": "validation failed"}')
+
+ @app.get("/my/path")
+ async def get_lambda():
+ await asyncio.sleep(0)
+ raise ValueError("bad input")
+
+ # WHEN resolving
+ _setup_app(app, API_REST_EVENT)
+ result = asyncio.run(app._resolve_async())
+
+ # THEN the exception handler catches the error
+ response = result.build(app.current_event, app._cors)
+ assert response["statusCode"] == 422
+
+
+class TestResolveAsyncMiddleware:
+ def test_sync_middleware_in_async_chain(self):
+ # GIVEN a sync middleware
+ app = APIGatewayRestResolver()
+
+ def my_middleware(app: ApiGatewayResolver, next_middleware: NextMiddleware):
+ app.append_context(sync_mw_called=True)
+ return next_middleware(app)
+
+ @app.get("/my/path", middlewares=[my_middleware])
+ async def get_lambda():
+ await asyncio.sleep(0)
+ return Response(200, content_types.TEXT_HTML, "with middleware")
+
+ # WHEN calling _resolve_async
+ _setup_app(app, API_REST_EVENT)
+ result = asyncio.run(app._resolve_async())
+
+ # THEN the sync middleware runs in the async chain
+ response = result.build(app.current_event, app._cors)
+ assert response["statusCode"] == 200
+ assert response["body"] == "with middleware"
+ assert app.context.get("sync_mw_called") is True
+
+ def test_async_middleware_in_async_chain(self):
+ # GIVEN an async middleware
+ app = APIGatewayRestResolver()
+
+ async def my_middleware(app: ApiGatewayResolver, next_middleware: NextMiddleware):
+ app.append_context(async_mw_called=True)
+ return await next_middleware(app)
+
+ @app.get("/my/path", middlewares=[my_middleware])
+ async def get_lambda():
+ await asyncio.sleep(0)
+ return Response(200, content_types.TEXT_HTML, "async mw")
+
+ # WHEN calling _resolve_async
+ _setup_app(app, API_REST_EVENT)
+ result = asyncio.run(app._resolve_async())
+
+ # THEN the async middleware runs correctly
+ response = result.build(app.current_event, app._cors)
+ assert response["statusCode"] == 200
+ assert app.context.get("async_mw_called") is True
+
+ def test_not_found_goes_through_middleware(self):
+ # GIVEN a global middleware
+ middleware_called = []
+
+ def tracking_middleware(app: ApiGatewayResolver, next_middleware: NextMiddleware):
+ middleware_called.append(True)
+ return next_middleware(app)
+
+ app = APIGatewayRestResolver()
+ app.use([tracking_middleware])
+
+ @app.get("/other/path")
+ def get_lambda():
+ return Response(200, content_types.TEXT_HTML, "not reached")
+
+ # WHEN resolving with a non-matching path
+ _setup_app(app, API_REST_EVENT)
+ result = asyncio.run(app._resolve_async())
+
+ # THEN the middleware still runs (404 goes through chain)
+ response = result.build(app.current_event, app._cors)
+ assert response["statusCode"] == 404
+ assert len(middleware_called) > 0
+
+
+class TestResolveAsyncProcessedStack:
+ def test_processed_stack_frames_recorded(self):
+ # GIVEN an async handler
+ app = APIGatewayRestResolver()
+
+ @app.get("/my/path")
+ async def get_lambda():
+ await asyncio.sleep(0)
+ return Response(200, content_types.TEXT_HTML, "ok")
+
+ # WHEN calling _resolve_async
+ _setup_app(app, API_REST_EVENT)
+ asyncio.run(app._resolve_async())
+
+ # THEN the processed stack frames are populated
+ assert len(app.processed_stack_frames) > 0
+ assert any("_registered_api_adapter_async" in frame for frame in app.processed_stack_frames)
+
+
+class TestResolveAsyncDebugMode:
+ def test_debug_mode_prints_middleware_stack(self, capsys):
+ # GIVEN a resolver with debug=True
+ app = APIGatewayRestResolver(debug=True)
+
+ @app.get("/my/path")
+ async def get_lambda():
+ await asyncio.sleep(0)
+ return Response(200, content_types.TEXT_HTML, "debug")
+
+ # WHEN calling _resolve_async
+ _setup_app(app, API_REST_EVENT)
+ asyncio.run(app._resolve_async())
+
+ # THEN the async middleware stack is printed
+ captured = capsys.readouterr()
+ assert "Async Middleware Stack:" in captured.out
+ assert "_registered_api_adapter_async" in captured.out
+
+
+class TestResolveAsyncExceptionNoHandler:
+ def test_unhandled_exception_reraises(self):
+ # GIVEN an async handler that raises with no matching exception handler
+ app = APIGatewayRestResolver()
+
+ @app.get("/my/path")
+ async def get_lambda():
+ await asyncio.sleep(0)
+ raise RuntimeError("unhandled")
+
+ # WHEN calling _resolve_async
+ _setup_app(app, API_REST_EVENT)
+
+ # THEN the exception propagates
+ with pytest.raises(RuntimeError, match="unhandled"):
+ asyncio.run(app._resolve_async())
+
+ def test_unhandled_exception_with_debug_returns_traceback(self):
+ # GIVEN a resolver with debug=True and no exception handler
+ app = APIGatewayRestResolver(debug=True)
+
+ @app.get("/my/path")
+ async def get_lambda():
+ await asyncio.sleep(0)
+ raise RuntimeError("debug error")
+
+ # WHEN calling _resolve_async
+ _setup_app(app, API_REST_EVENT)
+ result = asyncio.run(app._resolve_async())
+
+ # THEN a 500 response with traceback is returned
+ response = result.build(app.current_event, app._cors)
+ assert response["statusCode"] == 500
+ assert "debug error" in response["body"]
+
+
+# ============================================================================
+# Public resolve_async() tests
+# ============================================================================
+
+
+class MockLambdaContext:
+ function_name = "test-func"
+ memory_limit_in_mb = 128
+ invoked_function_arn = "arn:aws:lambda:eu-west-1:123456789012:function:test-func"
+ aws_request_id = "52fdfc07-2182-154f-163f-5f0f9a621d72"
+
+ def get_remaining_time_in_millis(self) -> int:
+ return 1000
+
+
+RESOLVE_ASYNC_IDS = ["APIGatewayRestResolver", "APIGatewayHttpResolver", "ALBResolver"]
+
+
+@pytest.fixture(
+ params=[
+ ("apigw_rest", API_REST_EVENT, "/my/path"),
+ ("apigw_v2", API_RESTV2_EVENT, "/my/path"),
+ ("alb", ALB_EVENT, "/lambda"),
+ ],
+ ids=RESOLVE_ASYNC_IDS,
+)
+def public_resolver_and_event(request):
+ key, event, path = request.param
+ resolvers = {
+ "apigw_rest": APIGatewayRestResolver(),
+ "apigw_v2": APIGatewayHttpResolver(),
+ "alb": ALBResolver(),
+ }
+ return resolvers[key], event, path
+
+
+class TestResolveAsyncPublic:
+ def test_resolve_async_returns_dict_response(self, public_resolver_and_event):
+ # GIVEN an async handler
+ app, event, path = public_resolver_and_event
+
+ @app.get(path)
+ async def get_lambda():
+ await asyncio.sleep(0)
+ return Response(200, content_types.TEXT_HTML, "async public")
+
+ # WHEN calling resolve_async with event and context
+ response = asyncio.run(app.resolve_async(event, MockLambdaContext()))
+
+ # THEN a dict response is returned directly (no need to call .build())
+ assert response["statusCode"] == 200
+ assert response["body"] == "async public"
+
+ def test_resolve_async_with_sync_handler(self, public_resolver_and_event):
+ # GIVEN a sync handler
+ app, event, path = public_resolver_and_event
+
+ @app.get(path)
+ def get_lambda():
+ return Response(200, content_types.TEXT_HTML, "sync via public async")
+
+ # WHEN calling resolve_async
+ response = asyncio.run(app.resolve_async(event, MockLambdaContext()))
+
+ # THEN sync handlers work through the async chain
+ assert response["statusCode"] == 200
+ assert response["body"] == "sync via public async"
+
+ def test_resolve_async_clears_context(self, public_resolver_and_event):
+ # GIVEN an async handler
+ app, event, path = public_resolver_and_event
+
+ @app.get(path)
+ async def get_lambda():
+ app.append_context(custom_key="value")
+ return Response(200, content_types.TEXT_HTML, "ok")
+
+ # WHEN calling resolve_async
+ asyncio.run(app.resolve_async(event, MockLambdaContext()))
+
+ # THEN the context is cleared after resolution
+ assert app.context == {}
+
+ def test_resolve_async_not_found(self, public_resolver_and_event):
+ # GIVEN no matching route
+ app, event, _path = public_resolver_and_event
+
+ @app.get("/non/existent/path")
+ async def get_lambda():
+ return Response(200, content_types.TEXT_HTML, "unreachable")
+
+ # WHEN calling resolve_async
+ response = asyncio.run(app.resolve_async(event, MockLambdaContext()))
+
+ # THEN a 404 response is returned
+ assert response["statusCode"] == 404
+
+ def test_resolve_async_with_cors(self):
+ # GIVEN a resolver with CORS and an async handler
+ app = APIGatewayRestResolver(cors=CORSConfig())
+
+ @app.get("/my/path")
+ async def get_lambda():
+ return Response(200, content_types.TEXT_HTML, "cors")
+
+ # WHEN calling resolve_async
+ response = asyncio.run(app.resolve_async(API_REST_EVENT, MockLambdaContext()))
+
+ # THEN CORS headers are included
+ assert response["statusCode"] == 200
+ assert "Access-Control-Allow-Origin" in response.get("multiValueHeaders", response.get("headers", {}))
+
+ def test_resolve_async_with_middleware(self):
+ # GIVEN a resolver with a middleware
+ app = APIGatewayRestResolver()
+ middleware_order = []
+
+ def tracking_middleware(app: ApiGatewayResolver, next_middleware: NextMiddleware):
+ middleware_order.append("before")
+ result = next_middleware(app)
+ middleware_order.append("after")
+ return result
+
+ @app.get("/my/path", middlewares=[tracking_middleware])
+ async def get_lambda():
+ middleware_order.append("handler")
+ return Response(200, content_types.TEXT_HTML, "ok")
+
+ # WHEN calling resolve_async
+ response = asyncio.run(app.resolve_async(API_REST_EVENT, MockLambdaContext()))
+
+ # THEN middleware runs in correct order around the handler
+ assert response["statusCode"] == 200
+ assert middleware_order == ["before", "handler", "after"]
+
+ def test_resolve_async_exception_handler(self):
+ # GIVEN an async handler that raises with an exception handler registered
+ app = APIGatewayRestResolver()
+
+ @app.exception_handler(ValueError)
+ def handle_value_error(exc):
+ return Response(422, content_types.APPLICATION_JSON, json.dumps({"error": str(exc)}))
+
+ @app.get("/my/path")
+ async def get_lambda():
+ raise ValueError("invalid input")
+
+ # WHEN calling resolve_async
+ response = asyncio.run(app.resolve_async(API_REST_EVENT, MockLambdaContext()))
+
+ # THEN the exception handler catches the error
+ assert response["statusCode"] == 422
+ assert "invalid input" in response["body"]
+
+ def test_resolve_async_debug_mode(self, capsys):
+ # GIVEN a resolver with debug=True
+ app = APIGatewayRestResolver(debug=True)
+
+ @app.get("/my/path")
+ async def get_lambda():
+ return Response(200, content_types.TEXT_HTML, "debug")
+
+ # WHEN calling resolve_async
+ response = asyncio.run(app.resolve_async(API_REST_EVENT, MockLambdaContext()))
+
+ # THEN debug output includes raw event and middleware stack
+ captured = capsys.readouterr()
+ assert response["statusCode"] == 200
+ assert "Processed Middlewares:" in captured.out
+ assert "httpMethod" in captured.out
+
+ def test_resolve_async_with_base_proxy_event(self):
+ # GIVEN a resolver and a BaseProxyEvent passed directly
+ from aws_lambda_powertools.utilities.data_classes import APIGatewayProxyEvent
+
+ app = APIGatewayRestResolver()
+
+ @app.get("/my/path")
+ async def get_lambda():
+ return Response(200, content_types.TEXT_HTML, "from proxy event")
+
+ # WHEN calling resolve_async with a data class instead of raw dict
+ proxy_event = APIGatewayProxyEvent(API_REST_EVENT)
+
+ with pytest.warns(UserWarning, match="You don't need to serialize event"):
+ response = asyncio.run(app.resolve_async(proxy_event, MockLambdaContext()))
+
+ # THEN it still works after extracting raw_event
+ assert response["statusCode"] == 200
+ assert response["body"] == "from proxy event"
diff --git a/tests/functional/event_handler/required_dependencies/test_router.py b/tests/functional/event_handler/required_dependencies/test_router.py
new file mode 100644
index 00000000000..05c2260c8ee
--- /dev/null
+++ b/tests/functional/event_handler/required_dependencies/test_router.py
@@ -0,0 +1,78 @@
+from __future__ import annotations
+
+from aws_lambda_powertools.event_handler import (
+ ALBResolver,
+ APIGatewayHttpResolver,
+ APIGatewayRestResolver,
+ LambdaFunctionUrlResolver,
+ Response,
+)
+from aws_lambda_powertools.event_handler.router import (
+ ALBRouter,
+ APIGatewayHttpRouter,
+ APIGatewayRouter,
+ LambdaFunctionUrlRouter,
+)
+from aws_lambda_powertools.utilities.data_classes import (
+ ALBEvent,
+ APIGatewayProxyEvent,
+ APIGatewayProxyEventV2,
+ LambdaFunctionUrlEvent,
+)
+from tests.functional.utils import load_event
+
+
+def test_alb_router_event_type():
+ app = ALBResolver()
+ router = ALBRouter()
+
+ @router.route(rule="/lambda", method=["GET"])
+ def foo():
+ assert type(router.current_event) is ALBEvent
+ return Response(status_code=200, body="routed")
+
+ app.include_router(router)
+ result = app(load_event("albEvent.json"), {})
+ assert result["body"] == "routed"
+
+
+def test_apigateway_router_event_type():
+ app = APIGatewayRestResolver()
+ router = APIGatewayRouter()
+
+ @router.route(rule="/my/path", method=["GET"])
+ def foo():
+ assert type(router.current_event) is APIGatewayProxyEvent
+ return Response(status_code=200, body="routed")
+
+ app.include_router(router)
+ result = app(load_event("apiGatewayProxyEvent.json"), {})
+ assert result["body"] == "routed"
+
+
+def test_apigatewayhttp_router_event_type():
+ app = APIGatewayHttpResolver()
+ router = APIGatewayHttpRouter()
+
+ @router.route(rule="/my/path", method=["POST"])
+ def foo():
+ assert type(router.current_event) is APIGatewayProxyEventV2
+ return Response(status_code=200, body="routed")
+
+ app.include_router(router)
+ result = app(load_event("apiGatewayProxyV2Event.json"), {})
+ assert result["body"] == "routed"
+
+
+def test_lambda_function_url_router_event_type():
+ app = LambdaFunctionUrlResolver()
+ router = LambdaFunctionUrlRouter()
+
+ @router.route(rule="/", method=["GET"])
+ def foo():
+ assert type(router.current_event) is LambdaFunctionUrlEvent
+ return Response(status_code=200, body="routed")
+
+ app.include_router(router)
+ result = app(load_event("lambdaFunctionUrlEvent.json"), {})
+ assert result["body"] == "routed"
diff --git a/tests/functional/event_handler/required_dependencies/test_vpc_lattice.py b/tests/functional/event_handler/required_dependencies/test_vpc_lattice.py
new file mode 100644
index 00000000000..73168a36408
--- /dev/null
+++ b/tests/functional/event_handler/required_dependencies/test_vpc_lattice.py
@@ -0,0 +1,79 @@
+from __future__ import annotations
+
+from aws_lambda_powertools.event_handler import (
+ Response,
+ VPCLatticeResolver,
+ content_types,
+)
+from aws_lambda_powertools.event_handler.api_gateway import CORSConfig
+from aws_lambda_powertools.utilities.data_classes import VPCLatticeEvent
+from tests.functional.utils import load_event
+
+
+def test_vpclattice_event():
+ # GIVEN a VPC Lattice event
+ app = VPCLatticeResolver()
+
+ @app.get("/testpath")
+ def foo():
+ assert isinstance(app.current_event, VPCLatticeEvent)
+ assert app.lambda_context == {}
+ return Response(200, content_types.TEXT_HTML, "foo")
+
+ # WHEN calling the event handler
+ result = app(load_event("vpcLatticeEvent.json"), {})
+
+ # THEN process event correctly
+ # AND set the current_event type as VPCLatticeEvent
+ assert result["statusCode"] == 200
+ assert result["headers"]["Content-Type"] == content_types.TEXT_HTML
+ assert result["body"] == "foo"
+
+
+def test_vpclattice_event_path_trailing_slash(json_dump):
+ # GIVEN a VPC Lattice event
+ app = VPCLatticeResolver()
+
+ @app.get("/testpath")
+ def foo():
+ assert isinstance(app.current_event, VPCLatticeEvent)
+ assert app.lambda_context == {}
+ return Response(200, content_types.TEXT_HTML, "foo")
+
+ # WHEN calling the event handler using path with trailing "/"
+ result = app(load_event("vpcLatticeEventPathTrailingSlash.json"), {})
+
+ # THEN
+ assert result["statusCode"] == 404
+ assert result["headers"]["Content-Type"] == content_types.APPLICATION_JSON
+ expected = {"statusCode": 404, "message": "Not found"}
+ assert result["body"] == json_dump(expected)
+
+
+def test_cors_preflight_body_is_empty_not_null():
+ # GIVEN CORS is configured
+ app = VPCLatticeResolver(cors=CORSConfig())
+
+ event = {"raw_path": "/my/request", "method": "OPTIONS", "headers": {}}
+
+ # WHEN calling the event handler
+ result = app(event, {})
+
+ # THEN there body should be empty strings
+ assert result["body"] == ""
+
+
+def test_vpclattice_url_no_matches():
+ # GIVEN a VPC Lattice event
+ app = VPCLatticeResolver()
+
+ @app.post("/no_match")
+ def foo():
+ raise RuntimeError()
+
+ # WHEN calling the event handler
+ result = app(load_event("vpcLatticeEvent.json"), {})
+
+ # THEN process event correctly
+ # AND return 404 because the event doesn't match any known route
+ assert result["statusCode"] == 404
diff --git a/tests/functional/event_handler/required_dependencies/test_vpc_latticev2.py b/tests/functional/event_handler/required_dependencies/test_vpc_latticev2.py
new file mode 100644
index 00000000000..a83fcb3c30d
--- /dev/null
+++ b/tests/functional/event_handler/required_dependencies/test_vpc_latticev2.py
@@ -0,0 +1,79 @@
+from __future__ import annotations
+
+from aws_lambda_powertools.event_handler import (
+ Response,
+ VPCLatticeV2Resolver,
+ content_types,
+)
+from aws_lambda_powertools.event_handler.api_gateway import CORSConfig
+from aws_lambda_powertools.utilities.data_classes import VPCLatticeEventV2
+from tests.functional.utils import load_event
+
+
+def test_vpclatticev2_event():
+ # GIVEN a VPC Lattice event
+ app = VPCLatticeV2Resolver()
+
+ @app.get("/newpath")
+ def foo():
+ assert isinstance(app.current_event, VPCLatticeEventV2)
+ assert app.lambda_context == {}
+ return Response(200, content_types.TEXT_HTML, "foo")
+
+ # WHEN calling the event handler
+ result = app(load_event("vpcLatticeV2Event.json"), {})
+
+ # THEN process event correctly
+ # AND set the current_event type as VPCLatticeEvent
+ assert result["statusCode"] == 200
+ assert result["headers"]["Content-Type"] == content_types.TEXT_HTML
+ assert result["body"] == "foo"
+
+
+def test_vpclatticev2_event_path_trailing_slash(json_dump):
+ # GIVEN a VPC Lattice event
+ app = VPCLatticeV2Resolver()
+
+ @app.get("/newpath")
+ def foo():
+ assert isinstance(app.current_event, VPCLatticeEventV2)
+ assert app.lambda_context == {}
+ return Response(200, content_types.TEXT_HTML, "foo")
+
+ # WHEN calling the event handler using path with trailing "/"
+ result = app(load_event("vpcLatticeEventV2PathTrailingSlash.json"), {})
+
+ # THEN
+ assert result["statusCode"] == 404
+ assert result["headers"]["Content-Type"] == content_types.APPLICATION_JSON
+ expected = {"statusCode": 404, "message": "Not found"}
+ assert result["body"] == json_dump(expected)
+
+
+def test_cors_preflight_body_is_empty_not_null():
+ # GIVEN CORS is configured
+ app = VPCLatticeV2Resolver(cors=CORSConfig())
+
+ event = {"path": "/my/request", "method": "OPTIONS", "headers": {}}
+
+ # WHEN calling the event handler
+ result = app(event, {})
+
+ # THEN there body should be empty strings
+ assert result["body"] == ""
+
+
+def test_vpclatticev2_url_no_matches():
+ # GIVEN a VPC Lattice event
+ app = VPCLatticeV2Resolver()
+
+ @app.post("/no_match")
+ def foo():
+ raise RuntimeError()
+
+ # WHEN calling the event handler
+ result = app(load_event("vpcLatticeV2Event.json"), {})
+
+ # THEN process event correctly
+ # AND return 404 because the event doesn't match any known route
+ assert result["statusCode"] == 404
diff --git a/tests/functional/feature_flags/_boto3/__init__.py b/tests/functional/feature_flags/_boto3/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/tests/functional/feature_flags/test_feature_flags.py b/tests/functional/feature_flags/_boto3/test_feature_flags.py
similarity index 72%
rename from tests/functional/feature_flags/test_feature_flags.py
rename to tests/functional/feature_flags/_boto3/test_feature_flags.py
index 416fe0be3ba..a4d271aba57 100644
--- a/tests/functional/feature_flags/test_feature_flags.py
+++ b/tests/functional/feature_flags/_boto3/test_feature_flags.py
@@ -1,7 +1,13 @@
-from typing import Dict, List, Optional
+from __future__ import annotations
+from io import BytesIO
+from json import dumps
+
+import boto3
import pytest
from botocore.config import Config
+from botocore.response import StreamingBody
+from botocore.stub import Stubber
from aws_lambda_powertools.utilities.feature_flags import (
ConfigurationStoreError,
@@ -19,6 +25,7 @@
FEATURE_DEFAULT_VAL_TYPE_KEY,
RULE_MATCH_VALUE,
RULES_KEY,
+ ModuloRangeValues,
RuleAction,
)
from aws_lambda_powertools.utilities.parameters import GetParameterError
@@ -30,19 +37,52 @@ def config():
def init_feature_flags(
- mocker, mock_schema: Dict, config: Config, envelope: str = "", jmespath_options: Optional[Dict] = None
+ mocker,
+ mock_schema: dict,
+ config: Config,
+ envelope: str = "",
+ jmespath_options: dict | None = None,
) -> FeatureFlags:
- mocked_get_conf = mocker.patch("aws_lambda_powertools.utilities.parameters.AppConfigProvider.get")
- mocked_get_conf.return_value = mock_schema
+ environment = "test_env"
+ application = "test_app"
+ name = "test_conf_name"
+ configuration_token = "foo"
+ mock_schema_to_bytes = dumps(mock_schema).encode()
+
+ client = boto3.client("appconfigdata", config=config)
+ stubber = Stubber(client)
+
+ stubber.add_response(
+ method="start_configuration_session",
+ expected_params={
+ "ConfigurationProfileIdentifier": name,
+ "ApplicationIdentifier": application,
+ "EnvironmentIdentifier": environment,
+ },
+ service_response={"InitialConfigurationToken": configuration_token},
+ )
+ stubber.add_response(
+ method="get_latest_configuration",
+ expected_params={"ConfigurationToken": configuration_token},
+ service_response={
+ "Configuration": StreamingBody(
+ raw_stream=BytesIO(mock_schema_to_bytes),
+ content_length=len(mock_schema_to_bytes),
+ ),
+ "NextPollConfigurationToken": configuration_token,
+ },
+ )
+ stubber.activate()
app_conf_fetcher = AppConfigStore(
- environment="test_env",
- application="test_app",
- name="test_conf_name",
+ environment=environment,
+ application=application,
+ name=name,
max_age=600,
- sdk_config=config,
envelope=envelope,
jmespath_options=jmespath_options,
+ boto_config=config,
+ boto3_client=client,
)
feature_flags: FeatureFlags = FeatureFlags(store=app_conf_fetcher)
return feature_flags
@@ -75,11 +115,11 @@ def test_flags_rule_does_not_match(mocker, config):
"action": RuleAction.EQUALS.value,
"key": "tenant_id",
"value": "345345435",
- }
+ },
],
- }
+ },
},
- }
+ },
}
feature_flags = init_feature_flags(mocker, mocked_app_config_schema, config)
@@ -122,11 +162,11 @@ def test_flags_conditions_no_match(mocker, config):
"action": RuleAction.EQUALS.value,
"key": "tenant_id",
"value": "345345435",
- }
+ },
],
- }
+ },
},
- }
+ },
}
feature_flags = init_feature_flags(mocker, mocked_app_config_schema, config)
toggle = feature_flags.evaluate(name="my_feature", context={"tenant_id": "6", "username": "a"}, default=False)
@@ -156,9 +196,9 @@ def test_flags_conditions_rule_not_match_multiple_conditions_match_only_one_cond
"value": "bbb",
},
],
- }
+ },
},
- }
+ },
}
feature_flags = init_feature_flags(mocker, mocked_app_config_schema, config)
toggle = feature_flags.evaluate(
@@ -194,9 +234,9 @@ def test_flags_conditions_rule_match_equal_multiple_conditions(mocker, config):
"value": username_val,
},
],
- }
+ },
},
- }
+ },
}
feature_flags = init_feature_flags(mocker, mocked_app_config_schema, config)
toggle = feature_flags.evaluate(
@@ -234,9 +274,9 @@ def test_flags_conditions_no_rule_match_equal_multiple_conditions(mocker, config
"value": "a",
},
],
- }
+ },
},
- }
+ },
}
feature_flags = init_feature_flags(mocker, mocked_app_config_schema, config)
toggle = feature_flags.evaluate(name="my_feature", context={"tenant_id": "6", "username": "a"}, default=False)
@@ -289,7 +329,7 @@ def test_flags_conditions_rule_match_multiple_actions_multiple_rules_multiple_co
],
},
},
- }
+ },
}
feature_flags = init_feature_flags(mocker, mocked_app_config_schema, config)
@@ -301,7 +341,9 @@ def test_flags_conditions_rule_match_multiple_actions_multiple_rules_multiple_co
assert toggle == expected_value_second_check
# match no rule
toggle = feature_flags.evaluate(
- name="my_feature", context={"tenant_id": "11114446", "username": "ab"}, default=False
+ name="my_feature",
+ context={"tenant_id": "11114446", "username": "ab"},
+ default=False,
)
assert toggle == expected_value_third_check
# feature doesn't exist
@@ -315,6 +357,7 @@ def test_flags_conditions_rule_match_multiple_actions_multiple_rules_multiple_co
# check a case where the feature exists but the rule doesn't match so we revert to the default value of the feature
+
# Check IN/NOT_IN/KEY_IN_VALUE/KEY_NOT_IN_VALUE/VALUE_IN_KEY/VALUE_NOT_IN_KEY conditions
def test_flags_match_rule_with_in_action(mocker, config):
expected_value = True
@@ -329,11 +372,11 @@ def test_flags_match_rule_with_in_action(mocker, config):
"action": RuleAction.IN.value,
"key": "tenant_id",
"value": ["6", "2"],
- }
+ },
],
- }
+ },
},
- }
+ },
}
feature_flags = init_feature_flags(mocker, mocked_app_config_schema, config)
toggle = feature_flags.evaluate(name="my_feature", context={"tenant_id": "6", "username": "a"}, default=False)
@@ -353,11 +396,11 @@ def test_flags_no_match_rule_with_in_action(mocker, config):
"action": RuleAction.IN.value,
"key": "tenant_id",
"value": ["8", "2"],
- }
+ },
],
- }
+ },
},
- }
+ },
}
feature_flags = init_feature_flags(mocker, mocked_app_config_schema, config)
toggle = feature_flags.evaluate(name="my_feature", context={"tenant_id": "6", "username": "a"}, default=False)
@@ -377,11 +420,11 @@ def test_flags_match_rule_with_not_in_action(mocker, config):
"action": RuleAction.NOT_IN.value,
"key": "tenant_id",
"value": ["10", "4"],
- }
+ },
],
- }
+ },
},
- }
+ },
}
feature_flags = init_feature_flags(mocker, mocked_app_config_schema, config)
toggle = feature_flags.evaluate(name="my_feature", context={"tenant_id": "6", "username": "a"}, default=False)
@@ -401,11 +444,11 @@ def test_flags_no_match_rule_with_not_in_action(mocker, config):
"action": RuleAction.NOT_IN.value,
"key": "tenant_id",
"value": ["6", "4"],
- }
+ },
],
- }
+ },
},
- }
+ },
}
feature_flags = init_feature_flags(mocker, mocked_app_config_schema, config)
toggle = feature_flags.evaluate(name="my_feature", context={"tenant_id": "6", "username": "a"}, default=False)
@@ -425,11 +468,11 @@ def test_flags_match_rule_with_key_in_value_action(mocker, config):
"action": RuleAction.KEY_IN_VALUE.value,
"key": "tenant_id",
"value": ["6", "2"],
- }
+ },
],
- }
+ },
},
- }
+ },
}
feature_flags = init_feature_flags(mocker, mocked_app_config_schema, config)
toggle = feature_flags.evaluate(name="my_feature", context={"tenant_id": "6", "username": "a"}, default=False)
@@ -449,11 +492,11 @@ def test_flags_no_match_rule_with_key_in_value_action(mocker, config):
"action": RuleAction.KEY_IN_VALUE.value,
"key": "tenant_id",
"value": ["8", "2"],
- }
+ },
],
- }
+ },
},
- }
+ },
}
feature_flags = init_feature_flags(mocker, mocked_app_config_schema, config)
toggle = feature_flags.evaluate(name="my_feature", context={"tenant_id": "6", "username": "a"}, default=False)
@@ -473,11 +516,11 @@ def test_flags_match_rule_with_key_not_in_value_action(mocker, config):
"action": RuleAction.KEY_NOT_IN_VALUE.value,
"key": "tenant_id",
"value": ["10", "4"],
- }
+ },
],
- }
+ },
},
- }
+ },
}
feature_flags = init_feature_flags(mocker, mocked_app_config_schema, config)
toggle = feature_flags.evaluate(name="my_feature", context={"tenant_id": "6", "username": "a"}, default=False)
@@ -497,11 +540,11 @@ def test_flags_no_match_rule_with_key_not_in_value_action(mocker, config):
"action": RuleAction.KEY_NOT_IN_VALUE.value,
"key": "tenant_id",
"value": ["6", "4"],
- }
+ },
],
- }
+ },
},
- }
+ },
}
feature_flags = init_feature_flags(mocker, mocked_app_config_schema, config)
toggle = feature_flags.evaluate(name="my_feature", context={"tenant_id": "6", "username": "a"}, default=False)
@@ -521,15 +564,17 @@ def test_flags_match_rule_with_value_in_key_action(mocker, config):
"action": RuleAction.VALUE_IN_KEY.value,
"key": "groups",
"value": "SYSADMIN",
- }
+ },
],
- }
+ },
},
- }
+ },
}
feature_flags = init_feature_flags(mocker, mocked_app_config_schema, config)
toggle = feature_flags.evaluate(
- name="my_feature", context={"tenant_id": "6", "username": "a", "groups": ["SYSADMIN", "IT"]}, default=False
+ name="my_feature",
+ context={"tenant_id": "6", "username": "a", "groups": ["SYSADMIN", "IT"]},
+ default=False,
)
assert toggle == expected_value
@@ -547,15 +592,17 @@ def test_flags_no_match_rule_with_value_in_key_action(mocker, config):
"action": RuleAction.VALUE_IN_KEY.value,
"key": "groups",
"value": "GUEST",
- }
+ },
],
- }
+ },
},
- }
+ },
}
feature_flags = init_feature_flags(mocker, mocked_app_config_schema, config)
toggle = feature_flags.evaluate(
- name="my_feature", context={"tenant_id": "6", "username": "a", "groups": ["SYSADMIN", "IT"]}, default=False
+ name="my_feature",
+ context={"tenant_id": "6", "username": "a", "groups": ["SYSADMIN", "IT"]},
+ default=False,
)
assert toggle == expected_value
@@ -573,15 +620,17 @@ def test_flags_match_rule_with_value_not_in_key_action(mocker, config):
"action": RuleAction.VALUE_NOT_IN_KEY.value,
"key": "groups",
"value": "GUEST",
- }
+ },
],
- }
+ },
},
- }
+ },
}
feature_flags = init_feature_flags(mocker, mocked_app_config_schema, config)
toggle = feature_flags.evaluate(
- name="my_feature", context={"tenant_id": "6", "username": "a", "groups": ["SYSADMIN", "IT"]}, default=False
+ name="my_feature",
+ context={"tenant_id": "6", "username": "a", "groups": ["SYSADMIN", "IT"]},
+ default=False,
)
assert toggle == expected_value
@@ -599,15 +648,17 @@ def test_flags_no_match_rule_with_value_not_in_key_action(mocker, config):
"action": RuleAction.VALUE_NOT_IN_KEY.value,
"key": "groups",
"value": "SYSADMIN",
- }
+ },
],
- }
+ },
},
- }
+ },
}
feature_flags = init_feature_flags(mocker, mocked_app_config_schema, config)
toggle = feature_flags.evaluate(
- name="my_feature", context={"tenant_id": "6", "username": "a", "groups": ["SYSADMIN", "IT"]}, default=False
+ name="my_feature",
+ context={"tenant_id": "6", "username": "a", "groups": ["SYSADMIN", "IT"]},
+ default=False,
)
assert toggle == expected_value
@@ -626,9 +677,9 @@ def test_multiple_features_enabled(mocker, config):
"action": RuleAction.IN.value,
"key": "tenant_id",
"value": ["6", "2"],
- }
+ },
],
- }
+ },
},
},
"my_feature2": {
@@ -639,7 +690,7 @@ def test_multiple_features_enabled(mocker, config):
},
}
feature_flags = init_feature_flags(mocker, mocked_app_config_schema, config)
- enabled_list: List[str] = feature_flags.get_enabled_features(context={"tenant_id": "6", "username": "a"})
+ enabled_list: list[str] = feature_flags.get_enabled_features(context={"tenant_id": "6", "username": "a"})
assert enabled_list == expected_value
@@ -705,7 +756,10 @@ def test_is_rule_matched_no_matches(mocker, config):
# WHEN calling _evaluate_conditions
result = feature_flags._evaluate_conditions(
- rule_name="dummy", feature_name="dummy", rule=rule, context=rules_context
+ rule_name="dummy",
+ feature_name="dummy",
+ rule=rule,
+ context=rules_context,
)
# THEN return False
@@ -734,11 +788,11 @@ def test_match_condition_with_dict_value(mocker, config):
"action": RuleAction.EQUALS.value,
"key": "tenant",
"value": {"tenant_id": "6", "username": "lessa"},
- }
+ },
],
- }
+ },
},
- }
+ },
}
feature_flags = init_feature_flags(mocker, mocked_app_config_schema, config)
ctx = {"tenant": {"tenant_id": "6", "username": "lessa"}}
@@ -775,6 +829,7 @@ def test_get_configuration_with_envelope_and_raw(mocker, config):
## Inequality test cases
##
+
# Test not equals
def test_flags_not_equal_no_match(mocker, config):
expected_value = False
@@ -789,15 +844,17 @@ def test_flags_not_equal_no_match(mocker, config):
"action": RuleAction.NOT_EQUALS.value,
"key": "tenant_id",
"value": "345345435",
- }
+ },
],
- }
+ },
},
- }
+ },
}
feature_flags = init_feature_flags(mocker, mocked_app_config_schema, config)
toggle = feature_flags.evaluate(
- name="my_feature", context={"tenant_id": "345345435", "username": "a"}, default=False
+ name="my_feature",
+ context={"tenant_id": "345345435", "username": "a"},
+ default=False,
)
assert toggle == expected_value
@@ -806,20 +863,20 @@ def test_flags_not_equal_match(mocker, config):
expected_value = True
mocked_app_config_schema = {
"my_feature": {
- "default": expected_value,
+ "default": False,
"rules": {
"tenant id not equals 345345435": {
- "when_match": True,
+ "when_match": expected_value,
"conditions": [
{
"action": RuleAction.NOT_EQUALS.value,
"key": "tenant_id",
"value": "345345435",
- }
+ },
],
- }
+ },
},
- }
+ },
}
feature_flags = init_feature_flags(mocker, mocked_app_config_schema, config)
toggle = feature_flags.evaluate(name="my_feature", context={"tenant_id": "", "username": "a"}, default=False)
@@ -840,11 +897,11 @@ def test_flags_less_than_no_match_1(mocker, config):
"action": RuleAction.KEY_LESS_THAN_VALUE.value,
"key": "current_date",
"value": "2021.10.31",
- }
+ },
],
- }
+ },
},
- }
+ },
}
feature_flags = init_feature_flags(mocker, mocked_app_config_schema, config)
toggle = feature_flags.evaluate(
@@ -868,11 +925,11 @@ def test_flags_less_than_no_match_2(mocker, config):
"action": RuleAction.KEY_LESS_THAN_VALUE.value,
"key": "current_date",
"value": "2021.10.31",
- }
+ },
],
- }
+ },
},
- }
+ },
}
feature_flags = init_feature_flags(mocker, mocked_app_config_schema, config)
toggle = feature_flags.evaluate(
@@ -887,20 +944,20 @@ def test_flags_less_than_match(mocker, config):
expected_value = True
mocked_app_config_schema = {
"my_feature": {
- "default": expected_value,
+ "default": False,
"rules": {
"Date less than 2021.10.31": {
- "when_match": True,
+ "when_match": expected_value,
"conditions": [
{
"action": RuleAction.KEY_LESS_THAN_VALUE.value,
"key": "current_date",
"value": "2021.10.31",
- }
+ },
],
- }
+ },
},
- }
+ },
}
feature_flags = init_feature_flags(mocker, mocked_app_config_schema, config)
toggle = feature_flags.evaluate(
@@ -925,11 +982,11 @@ def test_flags_less_than_or_equal_no_match(mocker, config):
"action": RuleAction.KEY_LESS_THAN_OR_EQUAL_VALUE.value,
"key": "current_date",
"value": "2021.10.31",
- }
+ },
],
- }
+ },
},
- }
+ },
}
feature_flags = init_feature_flags(mocker, mocked_app_config_schema, config)
toggle = feature_flags.evaluate(
@@ -944,20 +1001,20 @@ def test_flags_less_than_or_equal_match_1(mocker, config):
expected_value = True
mocked_app_config_schema = {
"my_feature": {
- "default": expected_value,
+ "default": False,
"rules": {
"Date less than or equal 2021.10.31": {
- "when_match": True,
+ "when_match": expected_value,
"conditions": [
{
"action": RuleAction.KEY_LESS_THAN_OR_EQUAL_VALUE.value,
"key": "current_date",
"value": "2021.10.31",
- }
+ },
],
- }
+ },
},
- }
+ },
}
feature_flags = init_feature_flags(mocker, mocked_app_config_schema, config)
toggle = feature_flags.evaluate(
@@ -972,20 +1029,20 @@ def test_flags_less_than_or_equal_match_2(mocker, config):
expected_value = True
mocked_app_config_schema = {
"my_feature": {
- "default": expected_value,
+ "default": False,
"rules": {
"Date less than or equal 2021.10.31": {
- "when_match": True,
+ "when_match": expected_value,
"conditions": [
{
"action": RuleAction.KEY_LESS_THAN_OR_EQUAL_VALUE.value,
"key": "current_date",
"value": "2021.10.31",
- }
+ },
],
- }
+ },
},
- }
+ },
}
feature_flags = init_feature_flags(mocker, mocked_app_config_schema, config)
toggle = feature_flags.evaluate(
@@ -1010,11 +1067,11 @@ def test_flags_greater_than_no_match_1(mocker, config):
"action": RuleAction.KEY_GREATER_THAN_VALUE.value,
"key": "current_date",
"value": "2021.10.31",
- }
+ },
],
- }
+ },
},
- }
+ },
}
feature_flags = init_feature_flags(mocker, mocked_app_config_schema, config)
toggle = feature_flags.evaluate(
@@ -1038,11 +1095,11 @@ def test_flags_greater_than_no_match_2(mocker, config):
"action": RuleAction.KEY_GREATER_THAN_VALUE.value,
"key": "current_date",
"value": "2021.10.31",
- }
+ },
],
- }
+ },
},
- }
+ },
}
feature_flags = init_feature_flags(mocker, mocked_app_config_schema, config)
toggle = feature_flags.evaluate(
@@ -1057,20 +1114,20 @@ def test_flags_greater_than_match(mocker, config):
expected_value = True
mocked_app_config_schema = {
"my_feature": {
- "default": expected_value,
+ "default": False,
"rules": {
"Date greater than 2021.10.31": {
- "when_match": True,
+ "when_match": expected_value,
"conditions": [
{
"action": RuleAction.KEY_GREATER_THAN_VALUE.value,
"key": "current_date",
"value": "2021.10.31",
- }
+ },
],
- }
+ },
},
- }
+ },
}
feature_flags = init_feature_flags(mocker, mocked_app_config_schema, config)
toggle = feature_flags.evaluate(
@@ -1095,11 +1152,11 @@ def test_flags_greater_than_or_equal_no_match(mocker, config):
"action": RuleAction.KEY_GREATER_THAN_OR_EQUAL_VALUE.value,
"key": "current_date",
"value": "2021.10.31",
- }
+ },
],
- }
+ },
},
- }
+ },
}
feature_flags = init_feature_flags(mocker, mocked_app_config_schema, config)
toggle = feature_flags.evaluate(
@@ -1114,20 +1171,20 @@ def test_flags_greater_than_or_equal_match_1(mocker, config):
expected_value = True
mocked_app_config_schema = {
"my_feature": {
- "default": expected_value,
+ "default": False,
"rules": {
"Date greater than or equal 2021.10.31": {
- "when_match": True,
+ "when_match": expected_value,
"conditions": [
{
"action": RuleAction.KEY_GREATER_THAN_OR_EQUAL_VALUE.value,
"key": "current_date",
"value": "2021.10.31",
- }
+ },
],
- }
+ },
},
- }
+ },
}
feature_flags = init_feature_flags(mocker, mocked_app_config_schema, config)
toggle = feature_flags.evaluate(
@@ -1142,20 +1199,20 @@ def test_flags_greater_than_or_equal_match_2(mocker, config):
expected_value = True
mocked_app_config_schema = {
"my_feature": {
- "default": expected_value,
+ "default": False,
"rules": {
"Date greater than or equal 2021.10.31": {
- "when_match": True,
+ "when_match": expected_value,
"conditions": [
{
"action": RuleAction.KEY_GREATER_THAN_OR_EQUAL_VALUE.value,
"key": "current_date",
"value": "2021.10.31",
- }
+ },
],
- }
+ },
},
- }
+ },
}
feature_flags = init_feature_flags(mocker, mocked_app_config_schema, config)
toggle = feature_flags.evaluate(
@@ -1166,6 +1223,103 @@ def test_flags_greater_than_or_equal_match_2(mocker, config):
assert toggle == expected_value
+# Test modulo range
+def test_flags_modulo_range_no_match(mocker, config):
+ expected_value = True
+ mocked_app_config_schema = {
+ "my_feature": {
+ "default": expected_value,
+ "rules": {
+ "tenant_id mod 100 less than 30": {
+ "when_match": False,
+ "conditions": [
+ {
+ "action": RuleAction.MODULO_RANGE.value,
+ "key": "tenant_id",
+ "value": {
+ ModuloRangeValues.BASE.value: 100,
+ ModuloRangeValues.START.value: 0,
+ ModuloRangeValues.END.value: 29,
+ },
+ },
+ ],
+ },
+ },
+ },
+ }
+ feature_flags = init_feature_flags(mocker, mocked_app_config_schema, config)
+ toggle = feature_flags.evaluate(
+ name="my_feature",
+ context={"tenant_id": 3453454, "username": "a"},
+ default=False,
+ )
+ assert toggle == expected_value
+
+
+def test_flags_modulo_range_match_1(mocker, config):
+ expected_value = True
+ mocked_app_config_schema = {
+ "my_feature": {
+ "default": False,
+ "rules": {
+ "tenant_id mod 100 less than 40": {
+ "when_match": expected_value,
+ "conditions": [
+ {
+ "action": RuleAction.MODULO_RANGE.value,
+ "key": "tenant_id",
+ "value": {
+ ModuloRangeValues.BASE.value: 100,
+ ModuloRangeValues.START.value: 0,
+ ModuloRangeValues.END.value: 39,
+ },
+ },
+ ],
+ },
+ },
+ },
+ }
+ feature_flags = init_feature_flags(mocker, mocked_app_config_schema, config)
+ toggle = feature_flags.evaluate(
+ name="my_feature",
+ context={"tenant_id": 345345435, "username": "a"},
+ default=False,
+ )
+ assert toggle == expected_value
+
+
+def test_flags_modulo_range_match_2(mocker, config):
+ expected_value = True
+ mocked_app_config_schema = {
+ "my_feature": {
+ "default": False,
+ "rules": {
+ "tenant_id mod 100 between 35 and 10 incl": {
+ "when_match": expected_value,
+ "conditions": [
+ {
+ "action": RuleAction.MODULO_RANGE.value,
+ "key": "tenant_id",
+ "value": {
+ ModuloRangeValues.BASE.value: 100,
+ ModuloRangeValues.START.value: 10,
+ ModuloRangeValues.END.value: 35,
+ },
+ },
+ ],
+ },
+ },
+ },
+ }
+ feature_flags = init_feature_flags(mocker, mocked_app_config_schema, config)
+ toggle = feature_flags.evaluate(
+ name="my_feature",
+ context={"tenant_id": 345345435, "username": "a"},
+ default=False,
+ )
+ assert toggle == expected_value
+
+
def test_non_boolean_feature_match(mocker, config):
expected_value = ["value1"]
# GIVEN
@@ -1181,11 +1335,11 @@ def test_non_boolean_feature_match(mocker, config):
CONDITION_ACTION: RuleAction.EQUALS.value,
CONDITION_KEY: "tenant_id",
CONDITION_VALUE: "345345435",
- }
+ },
],
- }
+ },
},
- }
+ },
}
# WHEN
@@ -1199,7 +1353,7 @@ def test_non_boolean_feature_with_no_rules(mocker, config):
expected_value = ["value1"]
# GIVEN
mocked_app_config_schema = {
- "my_feature": {FEATURE_DEFAULT_VAL_KEY: expected_value, FEATURE_DEFAULT_VAL_TYPE_KEY: False}
+ "my_feature": {FEATURE_DEFAULT_VAL_KEY: expected_value, FEATURE_DEFAULT_VAL_TYPE_KEY: False},
}
# WHEN
features = init_feature_flags(mocker, mocked_app_config_schema, config)
@@ -1222,11 +1376,11 @@ def test_non_boolean_feature_with_no_rule_match(mocker, config):
CONDITION_ACTION: RuleAction.EQUALS.value,
CONDITION_KEY: "tenant_id",
CONDITION_VALUE: "345345435",
- }
+ },
],
- }
+ },
},
- }
+ },
}
features = init_feature_flags(mocker, mocked_app_config_schema, config)
@@ -1247,9 +1401,9 @@ def test_get_all_enabled_features_boolean_and_non_boolean(mocker, config):
CONDITION_ACTION: RuleAction.IN.value,
CONDITION_KEY: "tenant_id",
CONDITION_VALUE: ["6", "2"],
- }
+ },
],
- }
+ },
},
},
"my_feature2": {
@@ -1269,7 +1423,7 @@ def test_get_all_enabled_features_boolean_and_non_boolean(mocker, config):
CONDITION_ACTION: RuleAction.EQUALS.value,
CONDITION_KEY: "username",
CONDITION_VALUE: "a",
- }
+ },
],
},
},
@@ -1277,7 +1431,7 @@ def test_get_all_enabled_features_boolean_and_non_boolean(mocker, config):
}
feature_flags = init_feature_flags(mocker, mocked_app_config_schema, config)
- enabled_list: List[str] = feature_flags.get_enabled_features(context={"tenant_id": "6", "username": "a"})
+ enabled_list: list[str] = feature_flags.get_enabled_features(context={"tenant_id": "6", "username": "a"})
assert enabled_list == expected_value
@@ -1289,5 +1443,262 @@ def test_get_all_enabled_features_non_boolean_truthy_defaults(mocker, config):
}
feature_flags = init_feature_flags(mocker, mocked_app_config_schema, config)
- enabled_list: List[str] = feature_flags.get_enabled_features(context={"tenant_id": "6", "username": "a"})
+ enabled_list: list[str] = feature_flags.get_enabled_features(context={"tenant_id": "6", "username": "a"})
assert enabled_list == expected_value
+
+
+def test_flags_any_in_value_match(mocker, config):
+ expected_value = True
+ mocked_app_config_schema = {
+ "my_feature": {
+ "default": False,
+ "rules": {
+ "tenant_id is in allowed list": {
+ "when_match": expected_value,
+ "conditions": [
+ {
+ "action": RuleAction.ANY_IN_VALUE.value,
+ "key": "tenant_id",
+ "value": ["Akua", "John", "Maria", "Pat"],
+ },
+ ],
+ },
+ },
+ },
+ }
+
+ feature_flags = init_feature_flags(mocker, mocked_app_config_schema, config)
+ toggle = feature_flags.evaluate(
+ name="my_feature",
+ context={"tenant_id": ["Akua"]},
+ default=False,
+ )
+ assert toggle == expected_value
+
+
+def test_flags_any_in_value_no_match(mocker, config):
+ expected_value = False
+ mocked_app_config_schema = {
+ "my_feature": {
+ "default": False,
+ "rules": {
+ "tenant_id is in allowed list": {
+ "when_match": expected_value,
+ "conditions": [
+ {
+ "action": RuleAction.ANY_IN_VALUE.value,
+ "key": "tenant_id",
+ "value": ["Akua", "John", "Maria", "Pat"],
+ },
+ ],
+ },
+ },
+ },
+ }
+
+ feature_flags = init_feature_flags(mocker, mocked_app_config_schema, config)
+ toggle = feature_flags.evaluate(
+ name="my_feature",
+ context={"tenant_id": ["Kwesi"]},
+ default=False,
+ )
+ assert toggle == expected_value
+
+
+def test_flags_all_in_value_match(mocker, config):
+ expected_value = True
+ mocked_app_config_schema = {
+ "my_feature": {
+ "default": False,
+ "rules": {
+ "tenant_id is in allowed list": {
+ "when_match": expected_value,
+ "conditions": [
+ {
+ "action": RuleAction.ALL_IN_VALUE.value,
+ "key": "tenant_id",
+ "value": ["Akua", "John", "Maria", "Pat"],
+ },
+ ],
+ },
+ },
+ },
+ }
+
+ feature_flags = init_feature_flags(mocker, mocked_app_config_schema, config)
+ toggle = feature_flags.evaluate(
+ name="my_feature",
+ context={"tenant_id": ["Akua"]},
+ default=False,
+ )
+
+ assert toggle == expected_value
+
+
+def test_flags_all_in_value_no_match(mocker, config):
+ expected_value = False
+ mocked_app_config_schema = {
+ "my_feature": {
+ "default": False,
+ "rules": {
+ "tenant_id is in allowed list": {
+ "when_match": expected_value,
+ "conditions": [
+ {
+ "action": RuleAction.ALL_IN_VALUE.value,
+ "key": "tenant_id",
+ "value": ["Akua", "John", "Maria", "Pat"],
+ },
+ ],
+ },
+ },
+ },
+ }
+
+ feature_flags = init_feature_flags(mocker, mocked_app_config_schema, config)
+ toggle = feature_flags.evaluate(
+ name="my_feature",
+ context={"tenant_id": ["Akua", "Mary"]},
+ default=False,
+ )
+
+ assert toggle == expected_value
+
+
+def test_flags_none_in_value_match(mocker, config):
+ expected_value = True
+ mocked_app_config_schema = {
+ "my_feature": {
+ "default": False,
+ "rules": {
+ "tenant_id is in allowed list": {
+ "when_match": expected_value,
+ "conditions": [
+ {
+ "action": RuleAction.NONE_IN_VALUE.value,
+ "key": "tenant_id",
+ "value": ["Akua", "John", "Maria", "Pat"],
+ },
+ ],
+ },
+ },
+ },
+ }
+
+ feature_flags = init_feature_flags(mocker, mocked_app_config_schema, config)
+ toggle = feature_flags.evaluate(
+ name="my_feature",
+ context={"tenant_id": ["Mary"]},
+ default=False,
+ )
+
+ assert toggle == expected_value
+
+
+def test_flags_none_in_value_no_match(mocker, config):
+ expected_value = False
+ mocked_app_config_schema = {
+ "my_feature": {
+ "default": False,
+ "rules": {
+ "tenant_id is in allowed list": {
+ "when_match": expected_value,
+ "conditions": [
+ {
+ "action": RuleAction.NONE_IN_VALUE.value,
+ "key": "tenant_id",
+ "value": ["Akua", "John", "Maria", "Pat"],
+ },
+ ],
+ },
+ },
+ },
+ }
+
+ feature_flags = init_feature_flags(mocker, mocked_app_config_schema, config)
+ toggle = feature_flags.evaluate(
+ name="my_feature",
+ context={"tenant_id": ["Pat"]},
+ default=False,
+ )
+
+ assert toggle == expected_value
+
+
+@pytest.mark.parametrize(
+ "intersection_action",
+ [
+ RuleAction.ALL_IN_VALUE.value,
+ RuleAction.ANY_IN_VALUE.value,
+ RuleAction.NONE_IN_VALUE.value,
+ ],
+)
+def test_intersection_non_list_value(mocker, config, intersection_action):
+ # GIVEN a schema with list intersection action
+ expected_value = False
+ mocked_app_config_schema = {
+ "my_feature": {
+ "default": False,
+ "rules": {
+ "tenant_id is in allowed list": {
+ "when_match": expected_value,
+ "conditions": [
+ {
+ "action": intersection_action,
+ "key": "tenant_id",
+ "value": ["Akua", "John", "Maria", "Pat"],
+ },
+ ],
+ },
+ },
+ },
+ }
+
+ feature_flags = init_feature_flags(mocker, mocked_app_config_schema, config)
+
+ # WHEN a context value isn't a list
+ toggle = feature_flags.evaluate(
+ name="my_feature",
+ context={"tenant_id": "not a list value"},
+ default=False,
+ )
+
+ # THEN TypeError should be swallowed and use default value
+ assert toggle == expected_value
+
+
+def test_exception_handler(mocker, config):
+ # GIVEN a schema with list intersection action
+ expected_value = False
+ mocked_app_config_schema = {
+ "my_feature": {
+ "default": False,
+ "rules": {
+ "tenant_id is in allowed list": {
+ "when_match": expected_value,
+ "conditions": [
+ {
+ "action": RuleAction.ANY_IN_VALUE.value,
+ "key": "tenant_id",
+ "value": ["Akua", "John", "Maria", "Pat"],
+ },
+ ],
+ },
+ },
+ },
+ }
+
+ feature_flags = init_feature_flags(mocker, mocked_app_config_schema, config)
+
+ @feature_flags.validation_exception_handler(ValueError)
+ def catch_exception(exc):
+ raise TypeError("re-raised")
+
+ # WHEN a context value isn't a list
+ # THEN exception handler should be able to intercept and raise, instead of returning `False`
+ with pytest.raises(TypeError):
+ feature_flags.evaluate(
+ name="my_feature",
+ context={"tenant_id": "not a list value"},
+ default=False,
+ )
diff --git a/tests/functional/feature_flags/_boto3/test_schema_validation.py b/tests/functional/feature_flags/_boto3/test_schema_validation.py
new file mode 100644
index 00000000000..afc7130505e
--- /dev/null
+++ b/tests/functional/feature_flags/_boto3/test_schema_validation.py
@@ -0,0 +1,1073 @@
+from __future__ import annotations
+
+import re
+
+import pytest
+
+from aws_lambda_powertools.utilities.feature_flags.exceptions import (
+ SchemaValidationError,
+)
+from aws_lambda_powertools.utilities.feature_flags.schema import (
+ CONDITION_ACTION,
+ CONDITION_KEY,
+ CONDITION_VALUE,
+ CONDITIONS_KEY,
+ FEATURE_DEFAULT_VAL_KEY,
+ FEATURE_DEFAULT_VAL_TYPE_KEY,
+ RULE_MATCH_VALUE,
+ RULES_KEY,
+ ConditionsValidator,
+ ModuloRangeValues,
+ RuleAction,
+ RulesValidator,
+ SchemaValidator,
+ TimeKeys,
+ TimeValues,
+)
+
+EMPTY_SCHEMA = {"": ""}
+
+
+def test_invalid_features_dict():
+ validator = SchemaValidator(schema=[])
+ with pytest.raises(SchemaValidationError):
+ validator.validate()
+
+
+def test_empty_features_not_fail():
+ validator = SchemaValidator(schema={})
+ validator.validate()
+
+
+@pytest.mark.parametrize(
+ "schema",
+ [
+ pytest.param({"my_feature": []}, id="feat_as_list"),
+ pytest.param({"my_feature": {}}, id="feat_empty_dict"),
+ pytest.param({"my_feature": {FEATURE_DEFAULT_VAL_KEY: "False"}}, id="feat_default_non_bool"),
+ pytest.param({"my_feature": {FEATURE_DEFAULT_VAL_KEY: False, RULES_KEY: "4"}}, id="feat_rules_non_dict"),
+ pytest.param("%<>[]{}|^", id="unsafe-rfc3986"),
+ ],
+)
+def test_invalid_feature(schema):
+ validator = SchemaValidator(schema)
+ with pytest.raises(SchemaValidationError):
+ validator.validate()
+
+
+def test_valid_feature_dict():
+ # empty rules list
+ schema = {"my_feature": {FEATURE_DEFAULT_VAL_KEY: False, RULES_KEY: []}}
+ validator = SchemaValidator(schema)
+ validator.validate()
+
+ # no rules list at all
+ schema = {"my_feature": {FEATURE_DEFAULT_VAL_KEY: False}}
+ validator = SchemaValidator(schema)
+ validator.validate()
+
+
+def test_invalid_feature_default_value_is_not_boolean():
+ # feature is boolean but default value is a number, not a boolean
+ schema = {"my_feature": {FEATURE_DEFAULT_VAL_KEY: 3, FEATURE_DEFAULT_VAL_TYPE_KEY: True, RULES_KEY: []}}
+ validator = SchemaValidator(schema)
+ with pytest.raises(SchemaValidationError):
+ validator.validate()
+
+
+def test_invalid_rule():
+ # rules list is not a list of dict
+ schema = {
+ "my_feature": {
+ FEATURE_DEFAULT_VAL_KEY: False,
+ RULES_KEY: [
+ "a",
+ "b",
+ ],
+ },
+ }
+ validator = SchemaValidator(schema)
+ with pytest.raises(SchemaValidationError):
+ validator.validate()
+
+ # rules RULE_MATCH_VALUE is not bool
+ schema = {
+ "my_feature": {
+ FEATURE_DEFAULT_VAL_KEY: False,
+ RULES_KEY: {
+ "tenant id equals 345345435": {
+ RULE_MATCH_VALUE: "False",
+ },
+ },
+ },
+ }
+ validator = SchemaValidator(schema)
+ with pytest.raises(SchemaValidationError):
+ validator.validate()
+
+ # missing conditions list
+ schema = {
+ "my_feature": {
+ FEATURE_DEFAULT_VAL_KEY: False,
+ RULES_KEY: {
+ "tenant id equals 345345435": {
+ RULE_MATCH_VALUE: False,
+ },
+ },
+ },
+ }
+ validator = SchemaValidator(schema)
+ with pytest.raises(SchemaValidationError):
+ validator.validate()
+
+ # condition list is empty
+ schema = {
+ "my_feature": {
+ FEATURE_DEFAULT_VAL_KEY: False,
+ RULES_KEY: {
+ "tenant id equals 345345435": {RULE_MATCH_VALUE: False, CONDITIONS_KEY: []},
+ },
+ },
+ }
+ validator = SchemaValidator(schema)
+ with pytest.raises(SchemaValidationError):
+ validator.validate()
+
+ # condition is invalid type, not list
+ schema = {
+ "my_feature": {
+ FEATURE_DEFAULT_VAL_KEY: False,
+ RULES_KEY: {
+ "tenant id equals 345345435": {RULE_MATCH_VALUE: False, CONDITIONS_KEY: {}},
+ },
+ },
+ }
+ validator = SchemaValidator(schema)
+ with pytest.raises(SchemaValidationError):
+ validator.validate()
+
+
+def test_invalid_condition():
+ # invalid condition action
+ schema = {
+ "my_feature": {
+ FEATURE_DEFAULT_VAL_KEY: False,
+ RULES_KEY: {
+ "tenant id equals 345345435": {
+ RULE_MATCH_VALUE: False,
+ CONDITIONS_KEY: {CONDITION_ACTION: "stuff", CONDITION_KEY: "a", CONDITION_VALUE: "a"},
+ },
+ },
+ },
+ }
+ validator = SchemaValidator(schema)
+ with pytest.raises(SchemaValidationError):
+ validator.validate()
+
+ # missing condition key and value
+ schema = {
+ "my_feature": {
+ FEATURE_DEFAULT_VAL_KEY: False,
+ RULES_KEY: {
+ "tenant id equals 345345435": {
+ RULE_MATCH_VALUE: False,
+ CONDITIONS_KEY: {CONDITION_ACTION: RuleAction.EQUALS.value},
+ },
+ },
+ },
+ }
+ validator = SchemaValidator(schema)
+ with pytest.raises(SchemaValidationError):
+ validator.validate()
+
+ # invalid condition key type, not string
+ schema = {
+ "my_feature": {
+ FEATURE_DEFAULT_VAL_KEY: False,
+ RULES_KEY: {
+ "tenant id equals 345345435": {
+ RULE_MATCH_VALUE: False,
+ CONDITIONS_KEY: {
+ CONDITION_ACTION: RuleAction.EQUALS.value,
+ CONDITION_KEY: 5,
+ CONDITION_VALUE: "a",
+ },
+ },
+ },
+ },
+ }
+ validator = SchemaValidator(schema)
+ with pytest.raises(SchemaValidationError):
+ validator.validate()
+
+
+def test_valid_condition_all_actions():
+ schema = {
+ "my_feature": {
+ FEATURE_DEFAULT_VAL_KEY: False,
+ RULES_KEY: {
+ "tenant id equals 645654 and username is a": {
+ RULE_MATCH_VALUE: True,
+ CONDITIONS_KEY: [
+ {
+ CONDITION_ACTION: RuleAction.EQUALS.value,
+ CONDITION_KEY: "tenant_id",
+ CONDITION_VALUE: "645654",
+ },
+ {
+ CONDITION_ACTION: RuleAction.STARTSWITH.value,
+ CONDITION_KEY: "username",
+ CONDITION_VALUE: "a",
+ },
+ {
+ CONDITION_ACTION: RuleAction.ENDSWITH.value,
+ CONDITION_KEY: "username",
+ CONDITION_VALUE: "a",
+ },
+ {
+ CONDITION_ACTION: RuleAction.IN.value,
+ CONDITION_KEY: "username",
+ CONDITION_VALUE: ["a", "b"],
+ },
+ {
+ CONDITION_ACTION: RuleAction.NOT_IN.value,
+ CONDITION_KEY: "username",
+ CONDITION_VALUE: ["c"],
+ },
+ {
+ CONDITION_ACTION: RuleAction.KEY_IN_VALUE.value,
+ CONDITION_KEY: "username",
+ CONDITION_VALUE: ["a", "b"],
+ },
+ {
+ CONDITION_ACTION: RuleAction.KEY_NOT_IN_VALUE.value,
+ CONDITION_KEY: "username",
+ CONDITION_VALUE: ["c"],
+ },
+ {
+ CONDITION_ACTION: RuleAction.VALUE_IN_KEY.value,
+ CONDITION_KEY: "groups",
+ CONDITION_VALUE: "SYSADMIN",
+ },
+ {
+ CONDITION_ACTION: RuleAction.VALUE_NOT_IN_KEY.value,
+ CONDITION_KEY: "groups",
+ CONDITION_VALUE: "GUEST",
+ },
+ ],
+ },
+ },
+ },
+ }
+ validator = SchemaValidator(schema)
+ validator.validate()
+
+
+def test_validate_condition_invalid_condition_type():
+ # GIVEN an invalid condition type of empty dict
+ condition = {}
+
+ # WHEN calling validate_condition
+ # THEN raise SchemaValidationError
+ with pytest.raises(SchemaValidationError, match="Feature rule condition must be a dictionary"):
+ ConditionsValidator.validate_condition(condition=condition, rule_name="dummy")
+
+
+def test_validate_condition_invalid_condition_action():
+ # GIVEN an invalid condition action of foo
+ condition = {"action": "INVALID", "key": "tenant_id", "value": "12345"}
+
+ # WHEN calling validate_condition
+ # THEN raise SchemaValidationError
+ with pytest.raises(SchemaValidationError, match="'action' value must be either"):
+ ConditionsValidator.validate_condition_action(condition=condition, rule_name="dummy")
+
+
+def test_validate_condition_invalid_condition_key():
+ # GIVEN a configuration with a missing "key"
+ condition = {"action": RuleAction.EQUALS.value, "value": "12345"}
+
+ # WHEN calling validate_condition
+ # THEN raise SchemaValidationError
+ with pytest.raises(SchemaValidationError, match="'key' value must be a non empty string"):
+ ConditionsValidator.validate_condition_key(condition=condition, rule_name="dummy")
+
+
+def test_validate_condition_missing_condition_value():
+ # GIVEN a configuration with a missing condition value
+ condition = {
+ "action": RuleAction.EQUALS.value,
+ "key": "tenant_id",
+ }
+
+ # WHEN calling validate_condition
+ with pytest.raises(SchemaValidationError, match="'value' key must not be null"):
+ ConditionsValidator.validate_condition_value(condition=condition, rule_name="dummy")
+
+
+def test_validate_condition_none_condition_value():
+ # GIVEN a configuration with a missing condition value
+ condition = {
+ "action": RuleAction.EQUALS.value,
+ "key": "tenant_id",
+ "value": None,
+ }
+
+ # WHEN calling validate_condition
+ with pytest.raises(SchemaValidationError, match="'value' key must not be null"):
+ ConditionsValidator.validate_condition_value(condition=condition, rule_name="dummy")
+
+
+def test_validate_condition_empty_condition_value():
+ # GIVEN a configuration with a missing condition value
+ condition = {
+ "action": RuleAction.EQUALS.value,
+ "key": "tenant_id",
+ "value": "",
+ }
+
+ # WHEN calling validate_condition
+ ConditionsValidator.validate_condition_value(condition=condition, rule_name="dummy")
+
+
+def test_validate_condition_valid_falsy_condition_value():
+ # GIVEN a configuration with a missing condition value
+ condition = {
+ "action": RuleAction.EQUALS.value,
+ "key": "tenant_id",
+ "value": 0,
+ }
+
+ # WHEN calling validate_condition
+ ConditionsValidator.validate_condition_value(condition=condition, rule_name="dummy")
+
+
+def test_validate_rule_invalid_rule_type():
+ # GIVEN an invalid rule type of empty list
+ # WHEN calling validate_rule
+ # THEN raise SchemaValidationError
+ with pytest.raises(SchemaValidationError, match="Feature rule must be a dictionary"):
+ RulesValidator.validate_rule(rule=[], rule_name="dummy", feature_name="dummy")
+
+
+def test_validate_rule_invalid_rule_name():
+ # GIVEN a rule name is empty
+ # WHEN calling validate_rule_name
+ # THEN raise SchemaValidationError
+ with pytest.raises(SchemaValidationError, match="Rule name key must have a non-empty string"):
+ RulesValidator.validate_rule_name(rule_name="", feature_name="dummy")
+
+
+def test_validate_rule_invalid_when_match_type_boolean_feature_is_set():
+ # GIVEN an invalid rule with non boolean when_match but feature type boolean
+ # WHEN calling validate_rule
+ # THEN raise SchemaValidationError
+ rule_name = "dummy"
+ rule = {
+ RULE_MATCH_VALUE: ["matched_value"],
+ CONDITIONS_KEY: {
+ CONDITION_ACTION: RuleAction.EQUALS.value,
+ CONDITION_KEY: 5,
+ CONDITION_VALUE: "a",
+ },
+ }
+ with pytest.raises(SchemaValidationError, match=f"rule_default_value' key must have be bool, rule={rule_name}"):
+ RulesValidator.validate_rule(rule=rule, rule_name=rule_name, feature_name="dummy", boolean_feature=True)
+
+
+def test_validate_rule_invalid_when_match_type_boolean_feature_is_not_set():
+ # GIVEN an invalid rule with non boolean when_match but feature type boolean. validate_rule is called without validate_rule=True # type: ignore # noqa: E501
+ # WHEN calling validate_rule
+ # THEN raise SchemaValidationError
+ rule_name = "dummy"
+ rule = {
+ RULE_MATCH_VALUE: ["matched_value"],
+ CONDITIONS_KEY: {
+ CONDITION_ACTION: RuleAction.EQUALS.value,
+ CONDITION_KEY: 5,
+ CONDITION_VALUE: "a",
+ },
+ }
+ with pytest.raises(SchemaValidationError, match=f"rule_default_value' key must have be bool, rule={rule_name}"):
+ RulesValidator.validate_rule(rule=rule, rule_name=rule_name, feature_name="dummy")
+
+
+def test_validate_rule_boolean_feature_is_set():
+ # GIVEN a rule with a boolean when_match and feature type boolean
+ # WHEN calling validate_rule
+ # THEN schema is validated and declared as valid
+ rule_name = "dummy"
+ rule = {
+ RULE_MATCH_VALUE: True,
+ CONDITIONS_KEY: {
+ CONDITION_ACTION: RuleAction.EQUALS.value,
+ CONDITION_KEY: 5,
+ CONDITION_VALUE: "a",
+ },
+ }
+ RulesValidator.validate_rule(rule=rule, rule_name=rule_name, feature_name="dummy", boolean_feature=True)
+
+
+def test_validate_time_condition_between_time_range_invalid_condition_key():
+ # GIVEN a configuration with a SCHEDULE_BETWEEN_TIME_RANGE action,
+ # value of between 11:11 to 23:59 and a key of CURRENT_DATETIME
+ condition = {
+ CONDITION_ACTION: RuleAction.SCHEDULE_BETWEEN_TIME_RANGE.value,
+ CONDITION_VALUE: {TimeValues.START.value: "11:11", TimeValues.END.value: "23:59"},
+ CONDITION_KEY: TimeKeys.CURRENT_DATETIME.value,
+ }
+ rule_name = "dummy"
+
+ # WHEN calling validate_condition
+ # THEN raise SchemaValidationError
+ with pytest.raises(
+ SchemaValidationError,
+ match=f"'condition with a 'SCHEDULE_BETWEEN_TIME_RANGE' action must have a 'CURRENT_TIME' condition key, rule={rule_name}", # noqa: E501
+ ):
+ ConditionsValidator.validate_condition_key(condition=condition, rule_name=rule_name)
+
+
+def test_validate_time_condition_between_time_range_invalid_condition_value():
+ # GIVEN a configuration with a SCHEDULE_BETWEEN_TIME_RANGE action, key CURRENT_TIME and invalid value of string
+ condition = {
+ CONDITION_ACTION: RuleAction.SCHEDULE_BETWEEN_TIME_RANGE.value,
+ CONDITION_VALUE: "11:00-22:33",
+ CONDITION_KEY: TimeKeys.CURRENT_TIME.value,
+ }
+ rule_name = "dummy"
+
+ # WHEN calling validate_condition
+ # THEN raise SchemaValidationError
+ with pytest.raises(
+ SchemaValidationError,
+ match=f"SCHEDULE_BETWEEN_TIME_RANGE action must have a dictionary with 'START' and 'END' keys, rule={rule_name}", # noqa: E501
+ ):
+ ConditionsValidator.validate_condition_value(condition=condition, rule_name=rule_name)
+
+
+def test_validate_time_condition_between_time_range_invalid_condition_value_no_start_time():
+ # GIVEN a configuration with a SCHEDULE_BETWEEN_TIME_RANGE action, key CURRENT_TIME and invalid value
+ # dict without START key
+ condition = {
+ CONDITION_ACTION: RuleAction.SCHEDULE_BETWEEN_TIME_RANGE.value,
+ CONDITION_VALUE: {TimeValues.END.value: "23:59"},
+ CONDITION_KEY: TimeKeys.CURRENT_TIME.value,
+ }
+ rule_name = "dummy"
+
+ # WHEN calling validate_condition
+ # THEN raise SchemaValidationError
+ with pytest.raises(
+ SchemaValidationError,
+ match="'START' and 'END' must be a valid time format",
+ ):
+ ConditionsValidator.validate_condition_value(condition=condition, rule_name=rule_name)
+
+
+def test_validate_time_condition_between_time_range_invalid_condition_value_no_end_time():
+ # GIVEN a configuration with a SCHEDULE_BETWEEN_TIME_RANGE action, key CURRENT_TIME and invalid value
+ # dict without END key
+ condition = {
+ CONDITION_ACTION: RuleAction.SCHEDULE_BETWEEN_TIME_RANGE.value,
+ CONDITION_VALUE: {TimeValues.START.value: "23:59"},
+ CONDITION_KEY: TimeKeys.CURRENT_TIME.value,
+ }
+ rule_name = "dummy"
+
+ # WHEN calling validate_condition
+ # THEN raise SchemaValidationError
+ with pytest.raises(SchemaValidationError, match="'START' and 'END' must be a valid time format"):
+ ConditionsValidator.validate_condition_value(condition=condition, rule_name=rule_name)
+
+
+def test_validate_time_condition_between_time_range_invalid_condition_value_invalid_start_time_type():
+ # GIVEN a configuration with a SCHEDULE_BETWEEN_TIME_RANGE action, key CURRENT_TIME and
+ # invalid START value as a number
+ condition = {
+ CONDITION_ACTION: RuleAction.SCHEDULE_BETWEEN_TIME_RANGE.value,
+ CONDITION_VALUE: {TimeValues.START.value: 4, TimeValues.END.value: "23:59"},
+ CONDITION_KEY: TimeKeys.CURRENT_TIME.value,
+ }
+ rule_name = "dummy"
+
+ # WHEN calling validate_condition
+ # THEN raise SchemaValidationError
+ with pytest.raises(
+ SchemaValidationError,
+ match=f"'START' and 'END' must be a non empty string, rule={rule_name}",
+ ):
+ ConditionsValidator.validate_condition_value(condition=condition, rule_name=rule_name)
+
+
+def test_validate_time_condition_between_time_range_invalid_condition_value_invalid_end_time_type():
+ # GIVEN a configuration with a SCHEDULE_BETWEEN_TIME_RANGE action, key CURRENT_TIME and
+ # invalid START value as a number
+ condition = {
+ CONDITION_ACTION: RuleAction.SCHEDULE_BETWEEN_TIME_RANGE.value,
+ CONDITION_VALUE: {TimeValues.START.value: "11:11", TimeValues.END.value: 4},
+ CONDITION_KEY: TimeKeys.CURRENT_TIME.value,
+ }
+ rule_name = "dummy"
+
+ # WHEN calling validate_condition
+ # THEN raise SchemaValidationError
+ with pytest.raises(
+ SchemaValidationError,
+ match=f"'START' and 'END' must be a non empty string, rule={rule_name}",
+ ):
+ ConditionsValidator.validate_condition_value(condition=condition, rule_name=rule_name)
+
+
+@pytest.mark.parametrize(
+ "cond_value",
+ [
+ {TimeValues.START.value: "11-11", TimeValues.END.value: "23:59"},
+ {TimeValues.START.value: "24:99", TimeValues.END.value: "23:59"},
+ ],
+)
+def test_validate_time_condition_between_time_range_invalid_condition_value_invalid_start_time_value(cond_value):
+ # GIVEN a configuration with a SCHEDULE_BETWEEN_TIME_RANGE action, key CURRENT_TIME and
+ # invalid START value as an invalid time format
+ condition = {
+ CONDITION_ACTION: RuleAction.SCHEDULE_BETWEEN_TIME_RANGE.value,
+ CONDITION_VALUE: cond_value,
+ CONDITION_KEY: TimeKeys.CURRENT_TIME.value,
+ }
+ rule_name = "dummy"
+ match_str = f"'START' and 'END' must be a valid time format, time_format=%H:%M, rule={rule_name}"
+ # WHEN calling validate_condition
+ # THEN raise SchemaValidationError
+ with pytest.raises(
+ SchemaValidationError,
+ match=match_str,
+ ):
+ ConditionsValidator.validate_condition_value(condition=condition, rule_name=rule_name)
+
+
+@pytest.mark.parametrize(
+ "cond_value",
+ [
+ {TimeValues.START.value: "10:11", TimeValues.END.value: "11-11"},
+ {TimeValues.START.value: "10:11", TimeValues.END.value: "999:59"},
+ ],
+)
+def test_validate_time_condition_between_time_range_invalid_condition_value_invalid_end_time_value(cond_value):
+ # GIVEN a configuration with a SCHEDULE_BETWEEN_TIME_RANGE action, key CURRENT_TIME and
+ # invalid END value as an invalid time format
+ condition = {
+ CONDITION_ACTION: RuleAction.SCHEDULE_BETWEEN_TIME_RANGE.value,
+ CONDITION_VALUE: cond_value,
+ CONDITION_KEY: TimeKeys.CURRENT_TIME.value,
+ }
+ rule_name = "dummy"
+ match_str = f"'START' and 'END' must be a valid time format, time_format=%H:%M, rule={rule_name}"
+ # WHEN calling validate_condition
+ # THEN raise SchemaValidationError
+ with pytest.raises(
+ SchemaValidationError,
+ match=match_str,
+ ):
+ ConditionsValidator.validate_condition_value(condition=condition, rule_name=rule_name)
+
+
+def test_validate_time_condition_between_time_range_invalid_timezone():
+ # GIVEN a configuration with a SCHEDULE_BETWEEN_TIME_RANGE action, key CURRENT_TIME and
+ # invalid timezone
+ condition = {
+ CONDITION_ACTION: RuleAction.SCHEDULE_BETWEEN_TIME_RANGE.value,
+ CONDITION_VALUE: {
+ TimeValues.START.value: "10:11",
+ TimeValues.END.value: "10:59",
+ TimeValues.TIMEZONE.value: "Europe/Tokyo",
+ },
+ CONDITION_KEY: TimeKeys.CURRENT_TIME.value,
+ }
+ rule_name = "dummy"
+ match_str = f"'TIMEZONE' value must represent a valid IANA timezone, rule={rule_name}"
+ # WHEN calling validate_condition
+ # THEN raise SchemaValidationError
+ with pytest.raises(
+ SchemaValidationError,
+ match=match_str,
+ ):
+ ConditionsValidator.validate_condition_value(condition=condition, rule_name=rule_name)
+
+
+def test_validate_time_condition_between_time_range_valid_timezone():
+ # GIVEN a configuration with a SCHEDULE_BETWEEN_TIME_RANGE action, key CURRENT_TIME and
+ # valid timezone
+ condition = {
+ CONDITION_ACTION: RuleAction.SCHEDULE_BETWEEN_TIME_RANGE.value,
+ CONDITION_VALUE: {
+ TimeValues.START.value: "10:11",
+ TimeValues.END.value: "10:59",
+ TimeValues.TIMEZONE.value: "Europe/Copenhagen",
+ },
+ CONDITION_KEY: TimeKeys.CURRENT_TIME.value,
+ }
+ # WHEN calling validate_condition
+ # THEN nothing is raised
+ ConditionsValidator.validate_condition_value(condition=condition, rule_name="dummy")
+
+
+def test_validate_time_condition_between_datetime_range_invalid_condition_key():
+ # GIVEN a configuration with a SCHEDULE_BETWEEN_DATETIME_RANGE action,
+ # value of between "2022-10-05T12:15:00Z" to "2022-10-10T12:15:00Z" and a key of CURRENT_TIME
+ condition = {
+ CONDITION_ACTION: RuleAction.SCHEDULE_BETWEEN_DATETIME_RANGE.value,
+ CONDITION_VALUE: {
+ TimeValues.START.value: "2022-10-05T12:15:00Z",
+ TimeValues.END.value: "2022-10-10T12:15:00Z",
+ },
+ CONDITION_KEY: TimeKeys.CURRENT_TIME.value,
+ }
+ rule_name = "dummy"
+
+ # WHEN calling validate_condition
+ # THEN raise SchemaValidationError
+ with pytest.raises(
+ SchemaValidationError,
+ match=f"'condition with a 'SCHEDULE_BETWEEN_DATETIME_RANGE' action must have a 'CURRENT_DATETIME' condition key, rule={rule_name}", # noqa: E501
+ ):
+ ConditionsValidator.validate_condition_key(condition=condition, rule_name=rule_name)
+
+
+def test_a_validate_time_condition_between_datetime_range_invalid_condition_value():
+ # GIVEN a configuration with a SCHEDULE_BETWEEN_DATETIME_RANGE action, key CURRENT_DATETIME and invalid value of string # noqa: E501
+ condition = {
+ CONDITION_ACTION: RuleAction.SCHEDULE_BETWEEN_DATETIME_RANGE.value,
+ CONDITION_VALUE: "11:00-22:33",
+ CONDITION_KEY: TimeKeys.CURRENT_DATETIME.value,
+ }
+ rule_name = "dummy"
+
+ # WHEN calling validate_condition
+ # THEN raise SchemaValidationError
+ with pytest.raises(
+ SchemaValidationError,
+ match=f"SCHEDULE_BETWEEN_DATETIME_RANGE action must have a dictionary with 'START' and 'END' keys, rule={rule_name}", # noqa: E501
+ ):
+ ConditionsValidator.validate_condition_value(condition=condition, rule_name=rule_name)
+
+
+def test_validate_time_condition_between_datetime_range_invalid_condition_value_no_start_time():
+ # GIVEN a configuration with a SCHEDULE_BETWEEN_DATETIME_RANGE action, key CURRENT_DATETIME and invalid value
+ # dict without START key
+ condition = {
+ CONDITION_ACTION: RuleAction.SCHEDULE_BETWEEN_DATETIME_RANGE.value,
+ CONDITION_VALUE: {TimeValues.END.value: "2022-10-10T12:15:00Z"},
+ CONDITION_KEY: TimeKeys.CURRENT_DATETIME.value,
+ }
+ rule_name = "dummy"
+
+ # WHEN calling validate_condition
+ # THEN raise SchemaValidationError
+ with pytest.raises(
+ SchemaValidationError,
+ match=f"'START' and 'END' must be a valid ISO8601 time format, rule={rule_name}",
+ ):
+ ConditionsValidator.validate_condition_value(condition=condition, rule_name=rule_name)
+
+
+def test_validate_time_condition_between_datetime_range_invalid_condition_value_no_end_time():
+ # GIVEN a configuration with a SCHEDULE_BETWEEN_DATETIME_RANGE action, key CURRENT_DATETIME and invalid value
+ # dict without END key
+ condition = {
+ CONDITION_ACTION: RuleAction.SCHEDULE_BETWEEN_DATETIME_RANGE.value,
+ CONDITION_VALUE: {TimeValues.START.value: "2022-10-10T12:15:00Z"},
+ CONDITION_KEY: TimeKeys.CURRENT_DATETIME.value,
+ }
+ rule_name = "dummy"
+
+ # WHEN calling validate_condition
+ # THEN raise SchemaValidationError
+ with pytest.raises(
+ SchemaValidationError,
+ match="'START' and 'END' must not include timezone information.*",
+ ):
+ ConditionsValidator.validate_condition_value(condition=condition, rule_name=rule_name)
+
+
+def test_validate_time_condition_between_datetime_range_invalid_condition_value_invalid_start_time_type():
+ # GIVEN a configuration with a SCHEDULE_BETWEEN_DATETIME_RANGE action, key CURRENT_DATETIME and
+ # invalid START value as a number
+ condition = {
+ CONDITION_ACTION: RuleAction.SCHEDULE_BETWEEN_DATETIME_RANGE.value,
+ CONDITION_VALUE: {TimeValues.START.value: 4, TimeValues.END.value: "2022-10-10T12:15:00Z"},
+ CONDITION_KEY: TimeKeys.CURRENT_DATETIME.value,
+ }
+ rule_name = "dummy"
+
+ # WHEN calling validate_condition
+ # THEN raise SchemaValidationError
+ with pytest.raises(
+ SchemaValidationError,
+ match=f"'START' and 'END' must be a non empty string, rule={rule_name}",
+ ):
+ ConditionsValidator.validate_condition_value(condition=condition, rule_name=rule_name)
+
+
+def test_validate_time_condition_between_datetime_range_invalid_condition_value_invalid_end_time_type():
+ # GIVEN a configuration with a SCHEDULE_BETWEEN_DATETIME_RANGE action, key CURRENT_DATETIME and
+ # invalid START value as a number
+ condition = {
+ CONDITION_ACTION: RuleAction.SCHEDULE_BETWEEN_DATETIME_RANGE.value,
+ CONDITION_VALUE: {TimeValues.END.value: 4, TimeValues.START.value: "2022-10-10T12:15:00Z"},
+ CONDITION_KEY: TimeKeys.CURRENT_DATETIME.value,
+ }
+ rule_name = "dummy"
+
+ # WHEN calling validate_condition
+ # THEN raise SchemaValidationError
+ with pytest.raises(
+ SchemaValidationError,
+ match=f"'START' and 'END' must be a non empty string, rule={rule_name}",
+ ):
+ ConditionsValidator.validate_condition_value(condition=condition, rule_name=rule_name)
+
+
+@pytest.mark.parametrize(
+ "cond_value",
+ [
+ {TimeValues.START.value: "11:11", TimeValues.END.value: "2022-10-10T12:15:00Z"},
+ {TimeValues.START.value: "24:99", TimeValues.END.value: "2022-10-10T12:15:00Z"},
+ {TimeValues.START.value: "2022-10-10T", TimeValues.END.value: "2022-10-10T12:15:00Z"},
+ ],
+)
+def test_validate_time_condition_between_datetime_range_invalid_condition_value_invalid_start_time_value(cond_value):
+ # GIVEN a configuration with a SCHEDULE_BETWEEN_DATETIME_RANGE action, key CURRENT_DATETIME and
+ # invalid START value as an invalid time format
+ condition = {
+ CONDITION_ACTION: RuleAction.SCHEDULE_BETWEEN_DATETIME_RANGE.value,
+ CONDITION_VALUE: cond_value,
+ CONDITION_KEY: TimeKeys.CURRENT_DATETIME.value,
+ }
+ rule_name = "dummy"
+ match_str = f"'START' and 'END' must be a valid ISO8601 time format, rule={rule_name}"
+ # WHEN calling validate_condition
+ # THEN raise SchemaValidationError
+ with pytest.raises(
+ SchemaValidationError,
+ match=match_str,
+ ):
+ ConditionsValidator.validate_condition_value(condition=condition, rule_name=rule_name)
+
+
+def test_validate_time_condition_between_datetime_range_invalid_condition_value_invalid_end_time_value():
+ # GIVEN a configuration with a SCHEDULE_BETWEEN_DATETIME_RANGE action, key CURRENT_DATETIME and
+ # invalid END value as an invalid time format
+ condition = {
+ CONDITION_ACTION: RuleAction.SCHEDULE_BETWEEN_DATETIME_RANGE.value,
+ CONDITION_VALUE: {TimeValues.END.value: "10:10", TimeValues.START.value: "2022-10-10T12:15:00"},
+ CONDITION_KEY: TimeKeys.CURRENT_DATETIME.value,
+ }
+ rule_name = "dummy"
+ match_str = f"'START' and 'END' must be a valid ISO8601 time format, rule={rule_name}"
+ # WHEN calling validate_condition
+ # THEN raise SchemaValidationError
+ with pytest.raises(SchemaValidationError, match=match_str):
+ ConditionsValidator.validate_condition_value(condition=condition, rule_name=rule_name)
+
+
+def test_validate_time_condition_between_datetime_range_including_timezone():
+ # GIVEN a configuration with a SCHEDULE_BETWEEN_DATETIME_RANGE action, key CURRENT_DATETIME and
+ # invalid START and END timestamps with timezone information
+ condition = {
+ CONDITION_ACTION: RuleAction.SCHEDULE_BETWEEN_DATETIME_RANGE.value,
+ CONDITION_VALUE: {TimeValues.END.value: "2022-10-10T11:15:00Z", TimeValues.START.value: "2022-10-10T12:15:00Z"},
+ CONDITION_KEY: TimeKeys.CURRENT_DATETIME.value,
+ }
+ rule_name = "dummy"
+ match_str = (
+ f"'START' and 'END' must not include timezone information. Set the timezone using the 'TIMEZONE' "
+ f"field, rule={rule_name} "
+ )
+ # WHEN calling validate_condition
+ # THEN raise SchemaValidationError
+ with pytest.raises(SchemaValidationError, match=match_str):
+ ConditionsValidator.validate_condition_value(condition=condition, rule_name=rule_name)
+
+
+def test_validate_time_condition_between_days_range_invalid_condition_key():
+ # GIVEN a configuration with a SCHEDULE_BETWEEN_DAYS_OF_WEEK action,
+ # value of SUNDAY and a key of CURRENT_TIME
+ condition = {
+ CONDITION_ACTION: RuleAction.SCHEDULE_BETWEEN_DAYS_OF_WEEK.value,
+ CONDITION_VALUE: {
+ TimeValues.DAYS.value: [TimeValues.SUNDAY.value],
+ },
+ CONDITION_KEY: TimeKeys.CURRENT_TIME.value,
+ }
+ rule_name = "dummy"
+
+ # WHEN calling validate_condition
+ # THEN raise SchemaValidationError
+ with pytest.raises(
+ SchemaValidationError,
+ match=f"'condition with a 'SCHEDULE_BETWEEN_DAYS_OF_WEEK' action must have a 'CURRENT_DAY_OF_WEEK' condition key, rule={rule_name}", # noqa: E501
+ ):
+ ConditionsValidator.validate_condition_key(condition=condition, rule_name=rule_name)
+
+
+def test_validate_time_condition_between_days_range_invalid_condition_type():
+ # GIVEN a configuration with a SCHEDULE_BETWEEN_DAYS_OF_WEEK action
+ # key CURRENT_DAY_OF_WEEK and invalid value type string
+ condition = {
+ CONDITION_ACTION: RuleAction.SCHEDULE_BETWEEN_DAYS_OF_WEEK.value,
+ CONDITION_VALUE: TimeValues.SATURDAY.value,
+ CONDITION_KEY: TimeKeys.CURRENT_DAY_OF_WEEK.value,
+ }
+ rule_name = "dummy"
+ match_str = f"condition with a CURRENT_DAY_OF_WEEK action must have a condition value dictionary with 'DAYS' and 'TIMEZONE' (optional) keys, rule={rule_name}" # noqa: E501
+ # WHEN calling validate_condition
+ # THEN raise SchemaValidationError
+ with pytest.raises(
+ SchemaValidationError,
+ match=re.escape(match_str),
+ ):
+ ConditionsValidator.validate_condition_value(condition=condition, rule_name=rule_name)
+
+
+@pytest.mark.parametrize(
+ "cond_value",
+ [
+ {TimeValues.DAYS.value: [TimeValues.SUNDAY.value, "funday"]},
+ {TimeValues.DAYS.value: [TimeValues.SUNDAY, TimeValues.MONDAY.value]},
+ ],
+)
+def test_validate_time_condition_between_days_range_invalid_condition_value(cond_value):
+ # GIVEN a configuration with a SCHEDULE_BETWEEN_DAYS_OF_WEEK action
+ # key CURRENT_DAY_OF_WEEK and invalid value not day string
+ condition = {
+ CONDITION_ACTION: RuleAction.SCHEDULE_BETWEEN_DAYS_OF_WEEK.value,
+ CONDITION_VALUE: cond_value,
+ CONDITION_KEY: TimeKeys.CURRENT_DAY_OF_WEEK.value,
+ }
+ rule_name = "dummy"
+ match_str = f"condition value DAYS must represent a day of the week in 'TimeValues' enum, rule={rule_name}" # noqa: E501
+ # WHEN calling validate_condition
+ # THEN raise SchemaValidationError
+ with pytest.raises(
+ SchemaValidationError,
+ match=match_str,
+ ):
+ ConditionsValidator.validate_condition_value(condition=condition, rule_name=rule_name)
+
+
+def test_validate_time_condition_between_days_range_invalid_timezone():
+ # GIVEN a configuration with a SCHEDULE_BETWEEN_DAYS_OF_WEEK action
+ # key CURRENT_DAY_OF_WEEK and an invalid timezone
+ condition = {
+ CONDITION_ACTION: RuleAction.SCHEDULE_BETWEEN_DAYS_OF_WEEK.value,
+ CONDITION_VALUE: {TimeValues.DAYS.value: [TimeValues.SUNDAY.value], TimeValues.TIMEZONE.value: "Europe/Tokyo"},
+ CONDITION_KEY: TimeKeys.CURRENT_DAY_OF_WEEK.value,
+ }
+ rule_name = "dummy"
+ match_str = f"'TIMEZONE' value must represent a valid IANA timezone, rule={rule_name}"
+ # WHEN calling validate_condition
+ # THEN raise SchemaValidationError
+ with pytest.raises(
+ SchemaValidationError,
+ match=match_str,
+ ):
+ ConditionsValidator.validate_condition_value(condition=condition, rule_name=rule_name)
+
+
+def test_validate_time_condition_between_days_range_valid_timezone():
+ # GIVEN a configuration with a SCHEDULE_BETWEEN_DAYS_OF_WEEK action
+ # key CURRENT_DAY_OF_WEEK and a valid timezone
+ condition = {
+ CONDITION_ACTION: RuleAction.SCHEDULE_BETWEEN_DAYS_OF_WEEK.value,
+ CONDITION_VALUE: {
+ TimeValues.DAYS.value: [TimeValues.SUNDAY.value],
+ TimeValues.TIMEZONE.value: "Europe/Copenhagen",
+ },
+ CONDITION_KEY: TimeKeys.CURRENT_DAY_OF_WEEK.value,
+ }
+ # WHEN calling validate_condition
+ # THEN nothing is raised
+ ConditionsValidator.validate_condition_value(condition=condition, rule_name="dummy")
+
+
+def test_validate_modulo_range_condition_invalid_value():
+ # GIVEN a condition with a MODULO_RANGE action and invalid value
+ condition = {CONDITION_ACTION: RuleAction.MODULO_RANGE.value, CONDITION_VALUE: "invalid", CONDITION_KEY: "a"}
+ rule_name = "dummy"
+ match_str = f"condition with a 'MODULO_RANGE' action must have a condition value type dictionary with 'BASE', 'START' and 'END' keys, rule={rule_name}" # noqa: E501
+ # WHEN calling validate_condition
+ # THEN raise SchemaValidationError
+ with pytest.raises(
+ SchemaValidationError,
+ match=match_str,
+ ):
+ ConditionsValidator.validate_condition_value(condition=condition, rule_name=rule_name)
+
+
+def test_validate_modulo_range_condition_missing_parameter():
+ # GIVEN a condition with a MODULO_RANGE action and missing required parameter
+ condition = {
+ CONDITION_ACTION: RuleAction.MODULO_RANGE.value,
+ CONDITION_VALUE: {
+ ModuloRangeValues.BASE.value: 100,
+ ModuloRangeValues.START.value: 0,
+ },
+ CONDITION_KEY: "a",
+ }
+ rule_name = "dummy"
+ match_str = f"condition with a 'MODULO_RANGE' action must have a condition value type dictionary with 'BASE', 'START' and 'END' keys, rule={rule_name}" # noqa: E501
+ # WHEN calling validate_condition
+ # THEN raise SchemaValidationError
+ with pytest.raises(
+ SchemaValidationError,
+ match=match_str,
+ ):
+ ConditionsValidator.validate_condition_value(condition=condition, rule_name=rule_name)
+
+
+def test_validate_modulo_range_condition_non_integer_parameters():
+ # GIVEN a condition with a MODULO_RANGE action and non integer parameters
+ condition = {
+ CONDITION_ACTION: RuleAction.MODULO_RANGE.value,
+ CONDITION_VALUE: {
+ ModuloRangeValues.BASE.value: "100",
+ ModuloRangeValues.START.value: "0",
+ ModuloRangeValues.END.value: "49",
+ },
+ CONDITION_KEY: "a",
+ }
+ rule_name = "dummy"
+ match_str = f"'BASE', 'START' and 'END' must be integers, rule={rule_name}"
+ # WHEN calling validate_condition
+ # THEN raise SchemaValidationError
+ with pytest.raises(
+ SchemaValidationError,
+ match=match_str,
+ ):
+ ConditionsValidator.validate_condition_value(condition=condition, rule_name=rule_name)
+
+
+def test_validate_modulo_range_condition_start_greater_than_end():
+ # GIVEN a condition with a MODULO_RANGE action and invalid parameters
+ condition = {
+ CONDITION_ACTION: RuleAction.MODULO_RANGE.value,
+ CONDITION_VALUE: {
+ ModuloRangeValues.BASE.value: 100,
+ ModuloRangeValues.START.value: 50,
+ ModuloRangeValues.END.value: 49,
+ },
+ CONDITION_KEY: "a",
+ }
+ rule_name = "dummy"
+ match_str = f"condition with 'MODULO_RANGE' action must satisfy 0 <= START <= END <= BASE-1, rule={rule_name}"
+ # WHEN calling validate_condition
+ # THEN raise SchemaValidationError
+ with pytest.raises(
+ SchemaValidationError,
+ match=match_str,
+ ):
+ ConditionsValidator.validate_condition_value(condition=condition, rule_name=rule_name)
+
+
+def test_validate_modulo_range_condition_start_less_than_zero():
+ # GIVEN a condition with a MODULO_RANGE action and invalid parameters
+ condition = {
+ CONDITION_ACTION: RuleAction.MODULO_RANGE.value,
+ CONDITION_VALUE: {
+ ModuloRangeValues.BASE.value: 100,
+ ModuloRangeValues.START.value: -10,
+ ModuloRangeValues.END.value: 49,
+ },
+ CONDITION_KEY: "a",
+ }
+ rule_name = "dummy"
+ match_str = f"condition with 'MODULO_RANGE' action must satisfy 0 <= START <= END <= BASE-1, rule={rule_name}"
+ # WHEN calling validate_condition
+ # THEN raise SchemaValidationError
+ with pytest.raises(
+ SchemaValidationError,
+ match=match_str,
+ ):
+ ConditionsValidator.validate_condition_value(condition=condition, rule_name=rule_name)
+
+
+def test_validate_modulo_range_condition_end_greater_than_equal_to_base():
+ # GIVEN a condition with a MODULO_RANGE action and invalid parameters
+ condition = {
+ CONDITION_ACTION: RuleAction.MODULO_RANGE.value,
+ CONDITION_VALUE: {
+ ModuloRangeValues.BASE.value: 100,
+ ModuloRangeValues.START.value: 0,
+ ModuloRangeValues.END.value: 100,
+ },
+ CONDITION_KEY: "a",
+ }
+ rule_name = "dummy"
+ match_str = f"condition with 'MODULO_RANGE' action must satisfy 0 <= START <= END <= BASE-1, rule={rule_name}"
+ # WHEN calling validate_condition
+ # THEN raise SchemaValidationError
+ with pytest.raises(
+ SchemaValidationError,
+ match=match_str,
+ ):
+ ConditionsValidator.validate_condition_value(condition=condition, rule_name=rule_name)
+
+
+def test_validate_modulo_range_condition_valid():
+ # GIVEN a condition with a MODULO_RANGE action and valid parameters
+ condition = {
+ CONDITION_ACTION: RuleAction.MODULO_RANGE.value,
+ CONDITION_VALUE: {
+ ModuloRangeValues.BASE.value: 100,
+ ModuloRangeValues.START.value: 0,
+ ModuloRangeValues.END.value: 19,
+ },
+ CONDITION_KEY: "a",
+ }
+ # WHEN calling validate_condition
+ # THEN nothing is raised
+ ConditionsValidator.validate_condition_value(condition=condition, rule_name="dummy")
+
+
+def test_validate_any_in_value_condition_invalid_value():
+ # GIVEN a schema with a ANY_IN_VALUE action with non-list value
+ condition = {
+ CONDITION_ACTION: RuleAction.ANY_IN_VALUE.value,
+ CONDITION_VALUE: "Gerald",
+ }
+
+ rule_name = "non-list value for ANY_IN_VALUE"
+
+ # WHEN calling validate_condition
+ # THEN raise SchemaValidationError
+ with pytest.raises(SchemaValidationError, match="ANY_IN_VALUE action must have a list"):
+ ConditionsValidator.validate_condition_value(condition=condition, rule_name=rule_name)
+
+
+def test_validate_all_in_value_condition_invalid_value():
+ # GIVEN a schema with a ANY_IN_VALUE action with non-list value
+ condition = {
+ CONDITION_ACTION: RuleAction.ALL_IN_VALUE.value,
+ CONDITION_VALUE: "Pat",
+ }
+
+ rule_name = "non-list value for ALL_IN_VALUE"
+
+ # WHEN calling validate_condition
+ # THEN raise SchemaValidationError
+ with pytest.raises(SchemaValidationError, match="ALL_IN_VALUE action must have a list"):
+ ConditionsValidator.validate_condition_value(condition=condition, rule_name=rule_name)
+
+
+def test_validate_none_in_value_condition_invalid_value():
+ # GIVEN a schema with a ANY_IN_VALUE action with non-list value
+ condition = {
+ CONDITION_ACTION: RuleAction.NONE_IN_VALUE.value,
+ CONDITION_VALUE: "Heitor",
+ }
+
+ rule_name = "non-list value for NONE_IN_VALUE"
+
+ # WHEN calling validate_condition
+ # THEN raise SchemaValidationError
+ with pytest.raises(SchemaValidationError, match="NONE_IN_VALUE action must have a list"):
+ ConditionsValidator.validate_condition_value(condition=condition, rule_name=rule_name)
diff --git a/tests/functional/feature_flags/_boto3/test_time_based_actions.py b/tests/functional/feature_flags/_boto3/test_time_based_actions.py
new file mode 100644
index 00000000000..640434f1f46
--- /dev/null
+++ b/tests/functional/feature_flags/_boto3/test_time_based_actions.py
@@ -0,0 +1,544 @@
+from __future__ import annotations
+
+import datetime
+from typing import TYPE_CHECKING, Any
+
+from botocore.config import Config
+from dateutil.tz import gettz
+
+from aws_lambda_powertools.utilities.feature_flags.appconfig import AppConfigStore
+from aws_lambda_powertools.utilities.feature_flags.feature_flags import FeatureFlags
+from aws_lambda_powertools.utilities.feature_flags.schema import (
+ CONDITION_ACTION,
+ CONDITION_KEY,
+ CONDITION_VALUE,
+ CONDITIONS_KEY,
+ FEATURE_DEFAULT_VAL_KEY,
+ RULE_MATCH_VALUE,
+ RULES_KEY,
+ RuleAction,
+ TimeKeys,
+ TimeValues,
+)
+
+if TYPE_CHECKING:
+ from aws_lambda_powertools.utilities.feature_flags.types import JSONType
+
+
+def evaluate_mocked_schema(
+ mocker,
+ rules: dict[str, Any],
+ mocked_time: tuple[int, int, int, int, int, int, datetime.tzinfo], # year, month, day, hour, minute, second
+ context: dict[str, Any] | None = None,
+) -> JSONType:
+ """
+ This helper does the following:
+ 1. mocks the current time
+ 2. mocks the feature flag payload returned from AppConfig
+ 3. evaluates the rules and return True for a rule match, otherwise a False
+ """
+
+ # Mock the current time
+ year, month, day, hour, minute, second, timezone = mocked_time
+ time = mocker.patch("aws_lambda_powertools.utilities.feature_flags.comparators._get_now_from_timezone")
+ time.return_value = datetime.datetime(
+ year=year,
+ month=month,
+ day=day,
+ hour=hour,
+ minute=minute,
+ second=second,
+ microsecond=0,
+ tzinfo=timezone,
+ )
+
+ # Mock the returned data from AppConfig
+ mocked_get_conf = mocker.patch("aws_lambda_powertools.utilities.parameters.AppConfigProvider.get")
+ mocked_get_conf.return_value = {
+ "my_feature": {
+ FEATURE_DEFAULT_VAL_KEY: False,
+ RULES_KEY: rules,
+ },
+ }
+
+ # Create a dummy AppConfigStore that returns our mocked FeatureFlag
+ app_conf_fetcher = AppConfigStore(
+ environment="test_env",
+ application="test_app",
+ name="test_conf_name",
+ max_age=600,
+ sdk_config=Config(region_name="us-east-1"),
+ )
+ feature_flags: FeatureFlags = FeatureFlags(store=app_conf_fetcher)
+
+ # Evaluate our feature flag
+ context = {} if context is None else context
+ return feature_flags.evaluate(
+ name="my_feature",
+ context=context,
+ default=False,
+ )
+
+
+def test_time_based_utc_in_between_time_range_rule_match(mocker):
+ assert evaluate_mocked_schema(
+ mocker=mocker,
+ rules={
+ "lambda time is between UTC 11:11-23:59": {
+ RULE_MATCH_VALUE: True,
+ CONDITIONS_KEY: [
+ {
+ CONDITION_ACTION: RuleAction.SCHEDULE_BETWEEN_TIME_RANGE.value, # this condition matches
+ CONDITION_KEY: TimeKeys.CURRENT_TIME.value,
+ CONDITION_VALUE: {TimeValues.START.value: "11:11", TimeValues.END.value: "23:59"},
+ },
+ ],
+ },
+ },
+ mocked_time=(2022, 2, 15, 11, 12, 0, datetime.timezone.utc),
+ )
+
+
+def test_time_based_utc_in_between_time_range_no_rule_match(mocker):
+ assert not evaluate_mocked_schema(
+ mocker=mocker,
+ rules={
+ "lambda time is between UTC 11:11-23:59": {
+ RULE_MATCH_VALUE: True,
+ CONDITIONS_KEY: [
+ {
+ CONDITION_ACTION: RuleAction.SCHEDULE_BETWEEN_TIME_RANGE.value, # this condition matches
+ CONDITION_KEY: TimeKeys.CURRENT_TIME.value,
+ CONDITION_VALUE: {TimeValues.START.value: "11:11", TimeValues.END.value: "23:59"},
+ },
+ ],
+ },
+ },
+ mocked_time=(2022, 2, 15, 7, 12, 0, datetime.timezone.utc), # no rule match 7:12 am
+ )
+
+
+def test_time_based_utc_in_between_time_range_full_hour_rule_match(mocker):
+ assert evaluate_mocked_schema(
+ mocker=mocker,
+ rules={
+ "lambda time is between UTC 20:00-23:00": {
+ RULE_MATCH_VALUE: True,
+ CONDITIONS_KEY: [
+ {
+ CONDITION_ACTION: RuleAction.SCHEDULE_BETWEEN_TIME_RANGE.value, # this condition matches
+ CONDITION_KEY: TimeKeys.CURRENT_TIME.value,
+ CONDITION_VALUE: {TimeValues.START.value: "20:00", TimeValues.END.value: "23:00"},
+ },
+ ],
+ },
+ },
+ mocked_time=(2022, 2, 15, 21, 12, 0, datetime.timezone.utc), # rule match 21:12
+ )
+
+
+def test_time_based_utc_in_between_time_range_between_days_rule_match(mocker):
+ assert evaluate_mocked_schema(
+ mocker=mocker,
+ rules={
+ "lambda time is between UTC 23:00-04:00": {
+ RULE_MATCH_VALUE: True,
+ CONDITIONS_KEY: [
+ {
+ CONDITION_ACTION: RuleAction.SCHEDULE_BETWEEN_TIME_RANGE.value, # this condition matches
+ CONDITION_KEY: TimeKeys.CURRENT_TIME.value,
+ CONDITION_VALUE: {TimeValues.START.value: "23:00", TimeValues.END.value: "04:00"},
+ },
+ ],
+ },
+ },
+ mocked_time=(2022, 2, 15, 2, 3, 0, datetime.timezone.utc), # rule match 2:03 am
+ )
+
+
+def test_time_based_utc_in_between_time_range_between_days_rule_no_match(mocker):
+ assert not evaluate_mocked_schema(
+ mocker=mocker,
+ rules={
+ "lambda time is between UTC 23:00-04:00": {
+ RULE_MATCH_VALUE: True,
+ CONDITIONS_KEY: [
+ {
+ CONDITION_ACTION: RuleAction.SCHEDULE_BETWEEN_TIME_RANGE.value, # this condition matches
+ CONDITION_KEY: TimeKeys.CURRENT_TIME.value,
+ CONDITION_VALUE: {TimeValues.START.value: "23:00", TimeValues.END.value: "04:00"},
+ },
+ ],
+ },
+ },
+ mocked_time=(2022, 2, 15, 5, 0, 0, datetime.timezone.utc), # rule no match 5:00 am
+ )
+
+
+def test_time_based_between_time_range_rule_timezone_match(mocker):
+ timezone_name = "Europe/Copenhagen"
+
+ assert evaluate_mocked_schema(
+ mocker=mocker,
+ rules={
+ "lambda time is between UTC 11:11-23:59, Copenhagen Time": {
+ RULE_MATCH_VALUE: True,
+ CONDITIONS_KEY: [
+ {
+ CONDITION_ACTION: RuleAction.SCHEDULE_BETWEEN_TIME_RANGE.value, # this condition matches
+ CONDITION_KEY: TimeKeys.CURRENT_TIME.value,
+ CONDITION_VALUE: {
+ TimeValues.START.value: "11:11",
+ TimeValues.END.value: "23:59",
+ TimeValues.TIMEZONE.value: timezone_name,
+ },
+ },
+ ],
+ },
+ },
+ mocked_time=(2022, 2, 15, 11, 11, 0, gettz(timezone_name)), # rule match 11:11 am, Europe/Copenhagen
+ )
+
+
+def test_time_based_between_time_range_rule_timezone_no_match(mocker):
+ timezone_name = "Europe/Copenhagen"
+
+ assert not evaluate_mocked_schema(
+ mocker=mocker,
+ rules={
+ "lambda time is between UTC 11:11-23:59, Copenhagen Time": {
+ RULE_MATCH_VALUE: True,
+ CONDITIONS_KEY: [
+ {
+ CONDITION_ACTION: RuleAction.SCHEDULE_BETWEEN_TIME_RANGE.value, # this condition matches
+ CONDITION_KEY: TimeKeys.CURRENT_TIME.value,
+ CONDITION_VALUE: {
+ TimeValues.START.value: "11:11",
+ TimeValues.END.value: "23:59",
+ TimeValues.TIMEZONE.value: timezone_name,
+ },
+ },
+ ],
+ },
+ },
+ mocked_time=(2022, 2, 15, 10, 11, 0, gettz(timezone_name)), # no rule match 10:11 am, Europe/Copenhagen
+ )
+
+
+def test_time_based_utc_in_between_full_time_range_rule_match(mocker):
+ assert evaluate_mocked_schema(
+ mocker=mocker,
+ rules={
+ "lambda time is between UTC october 5th 2022 12:14:32PM to october 10th 2022 12:15:00 PM": {
+ RULE_MATCH_VALUE: True,
+ CONDITIONS_KEY: [
+ {
+ CONDITION_ACTION: RuleAction.SCHEDULE_BETWEEN_DATETIME_RANGE.value, # condition matches
+ CONDITION_KEY: TimeKeys.CURRENT_DATETIME.value,
+ CONDITION_VALUE: {
+ TimeValues.START.value: "2022-10-05T12:15:00",
+ TimeValues.END.value: "2022-10-10T12:15:00",
+ },
+ },
+ ],
+ },
+ },
+ mocked_time=(2022, 10, 7, 10, 0, 0, datetime.timezone.utc), # will match rule
+ )
+
+
+def test_time_based_utc_in_between_full_time_range_no_rule_match(mocker):
+ timezone_name = "Europe/Copenhagen"
+
+ assert not evaluate_mocked_schema(
+ mocker=mocker,
+ rules={
+ "lambda time is between UTC october 5th 2022 12:14:32PM to october 10th 2022 12:15:00 PM": {
+ RULE_MATCH_VALUE: True,
+ CONDITIONS_KEY: [
+ {
+ CONDITION_ACTION: RuleAction.SCHEDULE_BETWEEN_DATETIME_RANGE.value, # condition matches
+ CONDITION_KEY: TimeKeys.CURRENT_DATETIME.value,
+ CONDITION_VALUE: {
+ TimeValues.START.value: "2022-10-05T12:15:00",
+ TimeValues.END.value: "2022-10-10T12:15:00",
+ TimeValues.TIMEZONE.value: timezone_name,
+ },
+ },
+ ],
+ },
+ },
+ mocked_time=(2022, 9, 7, 10, 0, 0, gettz(timezone_name)), # will not rule match
+ )
+
+
+def test_time_based_utc_in_between_full_time_range_timezone_no_match(mocker):
+ assert not evaluate_mocked_schema(
+ mocker=mocker,
+ rules={
+ "lambda time is between UTC october 5th 2022 12:14:32PM to october 10th 2022 12:15:00 PM": {
+ RULE_MATCH_VALUE: True,
+ CONDITIONS_KEY: [
+ {
+ CONDITION_ACTION: RuleAction.SCHEDULE_BETWEEN_DATETIME_RANGE.value, # condition matches
+ CONDITION_KEY: TimeKeys.CURRENT_DATETIME.value,
+ CONDITION_VALUE: {
+ TimeValues.START.value: "2022-10-05T12:15:00",
+ TimeValues.END.value: "2022-10-10T12:15:00",
+ TimeValues.TIMEZONE.value: "Europe/Copenhagen",
+ },
+ },
+ ],
+ },
+ },
+ mocked_time=(2022, 10, 10, 12, 15, 0, gettz("America/New_York")), # will not rule match, it's too late
+ )
+
+
+def test_time_based_multiple_conditions_utc_in_between_time_range_rule_match(mocker):
+ assert evaluate_mocked_schema(
+ mocker=mocker,
+ rules={
+ "lambda time is between UTC 09:00-17:00 and username is ran": {
+ RULE_MATCH_VALUE: True,
+ CONDITIONS_KEY: [
+ {
+ CONDITION_ACTION: RuleAction.SCHEDULE_BETWEEN_TIME_RANGE.value, # this condition matches
+ CONDITION_KEY: TimeKeys.CURRENT_TIME.value,
+ CONDITION_VALUE: {TimeValues.START.value: "09:00", TimeValues.END.value: "17:00"},
+ },
+ {
+ CONDITION_ACTION: RuleAction.EQUALS.value,
+ CONDITION_KEY: "username",
+ CONDITION_VALUE: "ran",
+ },
+ ],
+ },
+ },
+ mocked_time=(2022, 10, 7, 10, 0, 0, datetime.timezone.utc), # will rule match
+ context={"username": "ran"},
+ )
+
+
+def test_time_based_multiple_conditions_utc_in_between_time_range_no_rule_match(mocker):
+ assert not evaluate_mocked_schema(
+ mocker=mocker,
+ rules={
+ "lambda time is between UTC 09:00-17:00 and username is ran": {
+ RULE_MATCH_VALUE: True,
+ CONDITIONS_KEY: [
+ {
+ CONDITION_ACTION: RuleAction.SCHEDULE_BETWEEN_TIME_RANGE.value, # this condition matches
+ CONDITION_KEY: TimeKeys.CURRENT_TIME.value,
+ CONDITION_VALUE: {TimeValues.START.value: "09:00", TimeValues.END.value: "17:00"},
+ },
+ {
+ CONDITION_ACTION: RuleAction.EQUALS.value,
+ CONDITION_KEY: "username",
+ CONDITION_VALUE: "ran",
+ },
+ ],
+ },
+ },
+ mocked_time=(2022, 10, 7, 7, 0, 0, datetime.timezone.utc), # will cause no rule match, 7:00
+ context={"username": "ran"},
+ )
+
+
+def test_time_based_utc_days_range_rule_match(mocker):
+ assert evaluate_mocked_schema(
+ mocker=mocker,
+ rules={
+ "match only monday through friday": {
+ RULE_MATCH_VALUE: True,
+ CONDITIONS_KEY: [
+ {
+ CONDITION_ACTION: RuleAction.SCHEDULE_BETWEEN_DAYS_OF_WEEK.value, # this condition matches
+ CONDITION_KEY: TimeKeys.CURRENT_DAY_OF_WEEK.value, # similar to "IN" actions
+ CONDITION_VALUE: {
+ TimeValues.DAYS.value: [
+ TimeValues.MONDAY.value,
+ TimeValues.TUESDAY.value,
+ TimeValues.WEDNESDAY.value,
+ TimeValues.THURSDAY.value,
+ TimeValues.FRIDAY.value,
+ ],
+ },
+ },
+ ],
+ },
+ },
+ mocked_time=(2022, 11, 18, 10, 0, 0, datetime.timezone.utc), # friday
+ )
+
+
+def test_time_based_utc_days_range_no_rule_match(mocker):
+ assert not evaluate_mocked_schema(
+ mocker=mocker,
+ rules={
+ "match only monday through friday": {
+ RULE_MATCH_VALUE: True,
+ CONDITIONS_KEY: [
+ {
+ CONDITION_ACTION: RuleAction.SCHEDULE_BETWEEN_DAYS_OF_WEEK.value, # this condition matches
+ CONDITION_KEY: TimeKeys.CURRENT_DAY_OF_WEEK.value, # similar to "IN" actions
+ CONDITION_VALUE: {
+ TimeValues.DAYS.value: [
+ TimeValues.MONDAY.value,
+ TimeValues.TUESDAY.value,
+ TimeValues.WEDNESDAY.value,
+ TimeValues.THURSDAY.value,
+ TimeValues.FRIDAY.value,
+ ],
+ },
+ },
+ ],
+ },
+ },
+ mocked_time=(2022, 11, 20, 10, 0, 0, datetime.timezone.utc), # sunday, no match
+ )
+
+
+def test_time_based_utc_only_weekend_rule_match(mocker):
+ assert evaluate_mocked_schema(
+ mocker=mocker,
+ rules={
+ "match only on weekend": {
+ RULE_MATCH_VALUE: True,
+ CONDITIONS_KEY: [
+ {
+ CONDITION_ACTION: RuleAction.SCHEDULE_BETWEEN_DAYS_OF_WEEK.value, # this condition matches
+ CONDITION_KEY: TimeKeys.CURRENT_DAY_OF_WEEK.value, # similar to "IN" actions
+ CONDITION_VALUE: {
+ TimeValues.DAYS.value: [TimeValues.SATURDAY.value, TimeValues.SUNDAY.value],
+ },
+ },
+ ],
+ },
+ },
+ mocked_time=(2022, 11, 19, 10, 0, 0, datetime.timezone.utc), # saturday
+ )
+
+
+def test_time_based_utc_only_weekend_with_timezone_rule_match(mocker):
+ timezone_name = "Europe/Copenhagen"
+
+ assert evaluate_mocked_schema(
+ mocker=mocker,
+ rules={
+ "match only on weekend": {
+ RULE_MATCH_VALUE: True,
+ CONDITIONS_KEY: [
+ {
+ CONDITION_ACTION: RuleAction.SCHEDULE_BETWEEN_DAYS_OF_WEEK.value, # this condition matches
+ CONDITION_KEY: TimeKeys.CURRENT_DAY_OF_WEEK.value, # similar to "IN" actions
+ CONDITION_VALUE: {
+ TimeValues.DAYS.value: [TimeValues.SATURDAY.value, TimeValues.SUNDAY.value],
+ TimeValues.TIMEZONE.value: timezone_name,
+ },
+ },
+ ],
+ },
+ },
+ mocked_time=(2022, 11, 19, 10, 0, 0, gettz(timezone_name)), # saturday
+ )
+
+
+def test_time_based_utc_only_weekend_with_timezone_rule_no_match(mocker):
+ timezone_name = "Europe/Copenhagen"
+
+ assert not evaluate_mocked_schema(
+ mocker=mocker,
+ rules={
+ "match only on weekend": {
+ RULE_MATCH_VALUE: True,
+ CONDITIONS_KEY: [
+ {
+ CONDITION_ACTION: RuleAction.SCHEDULE_BETWEEN_DAYS_OF_WEEK.value, # this condition matches
+ CONDITION_KEY: TimeKeys.CURRENT_DAY_OF_WEEK.value, # similar to "IN" actions
+ CONDITION_VALUE: {
+ TimeValues.DAYS.value: [TimeValues.SATURDAY.value, TimeValues.SUNDAY.value],
+ TimeValues.TIMEZONE.value: timezone_name,
+ },
+ },
+ ],
+ },
+ },
+ mocked_time=(2022, 11, 21, 0, 0, 0, gettz("Europe/Copenhagen")), # monday, 00:00
+ )
+
+
+def test_time_based_utc_only_weekend_no_rule_match(mocker):
+ assert not evaluate_mocked_schema(
+ mocker=mocker,
+ rules={
+ "match only on weekend": {
+ RULE_MATCH_VALUE: True,
+ CONDITIONS_KEY: [
+ {
+ CONDITION_ACTION: RuleAction.SCHEDULE_BETWEEN_DAYS_OF_WEEK.value, # this condition matches
+ CONDITION_KEY: TimeKeys.CURRENT_DAY_OF_WEEK.value, # similar to "IN" actions
+ CONDITION_VALUE: {
+ TimeValues.DAYS.value: [TimeValues.SATURDAY.value, TimeValues.SUNDAY.value],
+ },
+ },
+ ],
+ },
+ },
+ mocked_time=(2022, 11, 18, 10, 0, 0, datetime.timezone.utc), # friday, no match
+ )
+
+
+def test_time_based_multiple_conditions_utc_days_range_and_certain_hours_rule_match(mocker):
+ assert evaluate_mocked_schema(
+ mocker=mocker,
+ rules={
+ "match when lambda time is between UTC 11:00-23:00 and day is either monday or thursday": {
+ RULE_MATCH_VALUE: True,
+ CONDITIONS_KEY: [
+ {
+ CONDITION_ACTION: RuleAction.SCHEDULE_BETWEEN_TIME_RANGE.value, # this condition matches
+ CONDITION_KEY: TimeKeys.CURRENT_TIME.value,
+ CONDITION_VALUE: {TimeValues.START.value: "11:00", TimeValues.END.value: "23:00"},
+ },
+ {
+ CONDITION_ACTION: RuleAction.SCHEDULE_BETWEEN_DAYS_OF_WEEK.value, # this condition matches
+ CONDITION_KEY: TimeKeys.CURRENT_DAY_OF_WEEK.value,
+ CONDITION_VALUE: {TimeValues.DAYS.value: [TimeValues.MONDAY.value, TimeValues.THURSDAY.value]},
+ },
+ ],
+ },
+ },
+ mocked_time=(2022, 11, 17, 16, 0, 0, datetime.timezone.utc), # thursday 16:00
+ )
+
+
+def test_time_based_multiple_conditions_utc_days_range_and_certain_hours_no_rule_match(mocker):
+ def evaluate(mocked_time: tuple[int, int, int, int, int, int, datetime.tzinfo]):
+ evaluate_mocked_schema(
+ mocker=mocker,
+ rules={
+ "match when lambda time is between UTC 11:00-23:00 and day is either monday or thursday": {
+ RULE_MATCH_VALUE: True,
+ CONDITIONS_KEY: [
+ {
+ CONDITION_ACTION: RuleAction.SCHEDULE_BETWEEN_TIME_RANGE.value,
+ CONDITION_KEY: TimeKeys.CURRENT_TIME.value,
+ CONDITION_VALUE: {TimeValues.START.value: "11:00", TimeValues.END.value: "23:00"},
+ },
+ {
+ CONDITION_ACTION: RuleAction.SCHEDULE_BETWEEN_DAYS_OF_WEEK.value,
+ CONDITION_KEY: TimeKeys.CURRENT_DAY_OF_WEEK.value,
+ CONDITION_VALUE: {
+ TimeValues.DAYS.value: [TimeValues.MONDAY.value, TimeValues.THURSDAY.value],
+ },
+ },
+ ],
+ },
+ },
+ mocked_time=mocked_time,
+ )
+
+ assert not evaluate(mocked_time=(2022, 11, 17, 9, 0, 0, datetime.timezone.utc)) # thursday 9:00
+ assert not evaluate(mocked_time=(2022, 11, 18, 13, 0, 0, datetime.timezone.utc)) # friday 16:00
+ assert not evaluate(mocked_time=(2022, 11, 18, 9, 0, 0, datetime.timezone.utc)) # friday 9:00
diff --git a/tests/functional/feature_flags/test_schema_validation.py b/tests/functional/feature_flags/test_schema_validation.py
deleted file mode 100644
index 0366a5609ee..00000000000
--- a/tests/functional/feature_flags/test_schema_validation.py
+++ /dev/null
@@ -1,368 +0,0 @@
-import logging
-
-import pytest # noqa: F401
-
-from aws_lambda_powertools.utilities.feature_flags.exceptions import (
- SchemaValidationError,
-)
-from aws_lambda_powertools.utilities.feature_flags.schema import (
- CONDITION_ACTION,
- CONDITION_KEY,
- CONDITION_VALUE,
- CONDITIONS_KEY,
- FEATURE_DEFAULT_VAL_KEY,
- FEATURE_DEFAULT_VAL_TYPE_KEY,
- RULE_MATCH_VALUE,
- RULES_KEY,
- ConditionsValidator,
- RuleAction,
- RulesValidator,
- SchemaValidator,
-)
-
-logger = logging.getLogger(__name__)
-
-EMPTY_SCHEMA = {"": ""}
-
-
-def test_invalid_features_dict():
- validator = SchemaValidator(schema=[])
- with pytest.raises(SchemaValidationError):
- validator.validate()
-
-
-def test_empty_features_not_fail():
- validator = SchemaValidator(schema={})
- validator.validate()
-
-
-@pytest.mark.parametrize(
- "schema",
- [
- pytest.param({"my_feature": []}, id="feat_as_list"),
- pytest.param({"my_feature": {}}, id="feat_empty_dict"),
- pytest.param({"my_feature": {FEATURE_DEFAULT_VAL_KEY: "False"}}, id="feat_default_non_bool"),
- pytest.param({"my_feature": {FEATURE_DEFAULT_VAL_KEY: False, RULES_KEY: "4"}}, id="feat_rules_non_dict"),
- pytest.param("%<>[]{}|^", id="unsafe-rfc3986"),
- ],
-)
-def test_invalid_feature(schema):
- validator = SchemaValidator(schema)
- with pytest.raises(SchemaValidationError):
- validator.validate()
-
-
-def test_valid_feature_dict():
- # empty rules list
- schema = {"my_feature": {FEATURE_DEFAULT_VAL_KEY: False, RULES_KEY: []}}
- validator = SchemaValidator(schema)
- validator.validate()
-
- # no rules list at all
- schema = {"my_feature": {FEATURE_DEFAULT_VAL_KEY: False}}
- validator = SchemaValidator(schema)
- validator.validate()
-
-
-def test_invalid_feature_default_value_is_not_boolean():
- # feature is boolean but default value is a number, not a boolean
- schema = {"my_feature": {FEATURE_DEFAULT_VAL_KEY: 3, FEATURE_DEFAULT_VAL_TYPE_KEY: True, RULES_KEY: []}}
- validator = SchemaValidator(schema)
- with pytest.raises(SchemaValidationError):
- validator.validate()
-
-
-def test_invalid_rule():
- # rules list is not a list of dict
- schema = {
- "my_feature": {
- FEATURE_DEFAULT_VAL_KEY: False,
- RULES_KEY: [
- "a",
- "b",
- ],
- }
- }
- validator = SchemaValidator(schema)
- with pytest.raises(SchemaValidationError):
- validator.validate()
-
- # rules RULE_MATCH_VALUE is not bool
- schema = {
- "my_feature": {
- FEATURE_DEFAULT_VAL_KEY: False,
- RULES_KEY: {
- "tenant id equals 345345435": {
- RULE_MATCH_VALUE: "False",
- }
- },
- }
- }
- validator = SchemaValidator(schema)
- with pytest.raises(SchemaValidationError):
- validator.validate()
-
- # missing conditions list
- schema = {
- "my_feature": {
- FEATURE_DEFAULT_VAL_KEY: False,
- RULES_KEY: {
- "tenant id equals 345345435": {
- RULE_MATCH_VALUE: False,
- }
- },
- }
- }
- validator = SchemaValidator(schema)
- with pytest.raises(SchemaValidationError):
- validator.validate()
-
- # condition list is empty
- schema = {
- "my_feature": {
- FEATURE_DEFAULT_VAL_KEY: False,
- RULES_KEY: {
- "tenant id equals 345345435": {RULE_MATCH_VALUE: False, CONDITIONS_KEY: []},
- },
- }
- }
- validator = SchemaValidator(schema)
- with pytest.raises(SchemaValidationError):
- validator.validate()
-
- # condition is invalid type, not list
- schema = {
- "my_feature": {
- FEATURE_DEFAULT_VAL_KEY: False,
- RULES_KEY: {
- "tenant id equals 345345435": {RULE_MATCH_VALUE: False, CONDITIONS_KEY: {}},
- },
- }
- }
- validator = SchemaValidator(schema)
- with pytest.raises(SchemaValidationError):
- validator.validate()
-
-
-def test_invalid_condition():
- # invalid condition action
- schema = {
- "my_feature": {
- FEATURE_DEFAULT_VAL_KEY: False,
- RULES_KEY: {
- "tenant id equals 345345435": {
- RULE_MATCH_VALUE: False,
- CONDITIONS_KEY: {CONDITION_ACTION: "stuff", CONDITION_KEY: "a", CONDITION_VALUE: "a"},
- }
- },
- }
- }
- validator = SchemaValidator(schema)
- with pytest.raises(SchemaValidationError):
- validator.validate()
-
- # missing condition key and value
- schema = {
- "my_feature": {
- FEATURE_DEFAULT_VAL_KEY: False,
- RULES_KEY: {
- "tenant id equals 345345435": {
- RULE_MATCH_VALUE: False,
- CONDITIONS_KEY: {CONDITION_ACTION: RuleAction.EQUALS.value},
- }
- },
- }
- }
- validator = SchemaValidator(schema)
- with pytest.raises(SchemaValidationError):
- validator.validate()
-
- # invalid condition key type, not string
- schema = {
- "my_feature": {
- FEATURE_DEFAULT_VAL_KEY: False,
- RULES_KEY: {
- "tenant id equals 345345435": {
- RULE_MATCH_VALUE: False,
- CONDITIONS_KEY: {
- CONDITION_ACTION: RuleAction.EQUALS.value,
- CONDITION_KEY: 5,
- CONDITION_VALUE: "a",
- },
- }
- },
- }
- }
- validator = SchemaValidator(schema)
- with pytest.raises(SchemaValidationError):
- validator.validate()
-
-
-def test_valid_condition_all_actions():
- schema = {
- "my_feature": {
- FEATURE_DEFAULT_VAL_KEY: False,
- RULES_KEY: {
- "tenant id equals 645654 and username is a": {
- RULE_MATCH_VALUE: True,
- CONDITIONS_KEY: [
- {
- CONDITION_ACTION: RuleAction.EQUALS.value,
- CONDITION_KEY: "tenant_id",
- CONDITION_VALUE: "645654",
- },
- {
- CONDITION_ACTION: RuleAction.STARTSWITH.value,
- CONDITION_KEY: "username",
- CONDITION_VALUE: "a",
- },
- {
- CONDITION_ACTION: RuleAction.ENDSWITH.value,
- CONDITION_KEY: "username",
- CONDITION_VALUE: "a",
- },
- {
- CONDITION_ACTION: RuleAction.IN.value,
- CONDITION_KEY: "username",
- CONDITION_VALUE: ["a", "b"],
- },
- {
- CONDITION_ACTION: RuleAction.NOT_IN.value,
- CONDITION_KEY: "username",
- CONDITION_VALUE: ["c"],
- },
- {
- CONDITION_ACTION: RuleAction.KEY_IN_VALUE.value,
- CONDITION_KEY: "username",
- CONDITION_VALUE: ["a", "b"],
- },
- {
- CONDITION_ACTION: RuleAction.KEY_NOT_IN_VALUE.value,
- CONDITION_KEY: "username",
- CONDITION_VALUE: ["c"],
- },
- {
- CONDITION_ACTION: RuleAction.VALUE_IN_KEY.value,
- CONDITION_KEY: "groups",
- CONDITION_VALUE: "SYSADMIN",
- },
- {
- CONDITION_ACTION: RuleAction.VALUE_NOT_IN_KEY.value,
- CONDITION_KEY: "groups",
- CONDITION_VALUE: "GUEST",
- },
- ],
- }
- },
- }
- }
- validator = SchemaValidator(schema)
- validator.validate()
-
-
-def test_validate_condition_invalid_condition_type():
- # GIVEN an invalid condition type of empty dict
- condition = {}
-
- # WHEN calling validate_condition
- # THEN raise SchemaValidationError
- with pytest.raises(SchemaValidationError, match="Feature rule condition must be a dictionary"):
- ConditionsValidator.validate_condition(condition=condition, rule_name="dummy")
-
-
-def test_validate_condition_invalid_condition_action():
- # GIVEN an invalid condition action of foo
- condition = {"action": "INVALID", "key": "tenant_id", "value": "12345"}
-
- # WHEN calling validate_condition
- # THEN raise SchemaValidationError
- with pytest.raises(SchemaValidationError, match="'action' value must be either"):
- ConditionsValidator.validate_condition_action(condition=condition, rule_name="dummy")
-
-
-def test_validate_condition_invalid_condition_key():
- # GIVEN a configuration with a missing "key"
- condition = {"action": RuleAction.EQUALS.value, "value": "12345"}
-
- # WHEN calling validate_condition
- # THEN raise SchemaValidationError
- with pytest.raises(SchemaValidationError, match="'key' value must be a non empty string"):
- ConditionsValidator.validate_condition_key(condition=condition, rule_name="dummy")
-
-
-def test_validate_condition_missing_condition_value():
- # GIVEN a configuration with a missing condition value
- condition = {
- "action": RuleAction.EQUALS.value,
- "key": "tenant_id",
- }
-
- # WHEN calling validate_condition
- with pytest.raises(SchemaValidationError, match="'value' key must not be empty"):
- ConditionsValidator.validate_condition_value(condition=condition, rule_name="dummy")
-
-
-def test_validate_rule_invalid_rule_type():
- # GIVEN an invalid rule type of empty list
- # WHEN calling validate_rule
- # THEN raise SchemaValidationError
- with pytest.raises(SchemaValidationError, match="Feature rule must be a dictionary"):
- RulesValidator.validate_rule(rule=[], rule_name="dummy", feature_name="dummy")
-
-
-def test_validate_rule_invalid_rule_name():
- # GIVEN a rule name is empty
- # WHEN calling validate_rule_name
- # THEN raise SchemaValidationError
- with pytest.raises(SchemaValidationError, match="Rule name key must have a non-empty string"):
- RulesValidator.validate_rule_name(rule_name="", feature_name="dummy")
-
-
-def test_validate_rule_invalid_when_match_type_boolean_feature_is_set():
- # GIVEN an invalid rule with non boolean when_match but feature type boolean
- # WHEN calling validate_rule
- # THEN raise SchemaValidationError
- rule_name = "dummy"
- rule = {
- RULE_MATCH_VALUE: ["matched_value"],
- CONDITIONS_KEY: {
- CONDITION_ACTION: RuleAction.EQUALS.value,
- CONDITION_KEY: 5,
- CONDITION_VALUE: "a",
- },
- }
- with pytest.raises(SchemaValidationError, match=f"rule_default_value' key must have be bool, rule={rule_name}"):
- RulesValidator.validate_rule(rule=rule, rule_name=rule_name, feature_name="dummy", boolean_feature=True)
-
-
-def test_validate_rule_invalid_when_match_type_boolean_feature_is_not_set():
- # GIVEN an invalid rule with non boolean when_match but feature type boolean. validate_rule is called without validate_rule=True # type: ignore # noqa: E501
- # WHEN calling validate_rule
- # THEN raise SchemaValidationError
- rule_name = "dummy"
- rule = {
- RULE_MATCH_VALUE: ["matched_value"],
- CONDITIONS_KEY: {
- CONDITION_ACTION: RuleAction.EQUALS.value,
- CONDITION_KEY: 5,
- CONDITION_VALUE: "a",
- },
- }
- with pytest.raises(SchemaValidationError, match=f"rule_default_value' key must have be bool, rule={rule_name}"):
- RulesValidator.validate_rule(rule=rule, rule_name=rule_name, feature_name="dummy")
-
-
-def test_validate_rule_boolean_feature_is_set():
- # GIVEN a rule with a boolean when_match and feature type boolean
- # WHEN calling validate_rule
- # THEN schema is validated and decalared as valid
- rule_name = "dummy"
- rule = {
- RULE_MATCH_VALUE: True,
- CONDITIONS_KEY: {
- CONDITION_ACTION: RuleAction.EQUALS.value,
- CONDITION_KEY: 5,
- CONDITION_VALUE: "a",
- },
- }
- RulesValidator.validate_rule(rule=rule, rule_name=rule_name, feature_name="dummy", boolean_feature=True)
diff --git a/tests/functional/idempotency/_boto3/__init__.py b/tests/functional/idempotency/_boto3/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/tests/functional/idempotency/conftest.py b/tests/functional/idempotency/_boto3/conftest.py
similarity index 57%
rename from tests/functional/idempotency/conftest.py
rename to tests/functional/idempotency/_boto3/conftest.py
index b5cf79727b1..cfc1d994619 100644
--- a/tests/functional/idempotency/conftest.py
+++ b/tests/functional/idempotency/_boto3/conftest.py
@@ -1,3 +1,5 @@
+from __future__ import annotations
+
import datetime
import json
from decimal import Decimal
@@ -11,7 +13,7 @@
from aws_lambda_powertools.utilities.idempotency import DynamoDBPersistenceLayer
from aws_lambda_powertools.utilities.idempotency.idempotency import IdempotencyConfig
-from aws_lambda_powertools.utilities.jmespath_utils import extract_data_from_envelope
+from aws_lambda_powertools.utilities.jmespath_utils import query
from aws_lambda_powertools.utilities.validation import envelopes
from tests.functional.idempotency.utils import hash_idempotency_key
from tests.functional.utils import json_serialize, load_event
@@ -29,18 +31,19 @@ def lambda_apigw_event():
return load_event("apiGatewayProxyV2Event.json")
-@pytest.fixture
-def lambda_context():
- class LambdaContext:
- def __init__(self):
- self.function_name = "test-func"
- self.memory_limit_in_mb = 128
- self.invoked_function_arn = "arn:aws:lambda:eu-west-1:809313241234:function:test-func"
- self.aws_request_id = "52fdfc07-2182-154f-163f-5f0f9a621d72"
+class LambdaContext:
+ def __init__(self):
+ self.function_name = "test-func"
+ self.memory_limit_in_mb = 128
+ self.invoked_function_arn = "arn:aws:lambda:eu-west-1:809313241234:function:test-func"
+ self.aws_request_id = "52fdfc07-2182-154f-163f-5f0f9a621d72"
- def get_remaining_time_in_millis(self) -> int:
- return 1000
+ def get_remaining_time_in_millis(self) -> int:
+ return 1000
+
+@pytest.fixture
+def lambda_context() -> LambdaContext:
return LambdaContext()
@@ -85,19 +88,21 @@ def expected_params_update_item(serialized_lambda_response, hashed_idempotency_k
"#status": "status",
},
"ExpressionAttributeValues": {
- ":expiry": stub.ANY,
- ":response_data": serialized_lambda_response,
- ":status": "COMPLETED",
+ ":expiry": {"N": stub.ANY},
+ ":response_data": {"S": serialized_lambda_response},
+ ":status": {"S": "COMPLETED"},
},
- "Key": {"id": hashed_idempotency_key},
+ "Key": {"id": {"S": hashed_idempotency_key}},
"TableName": "TEST_TABLE",
- "UpdateExpression": "SET #response_data = :response_data, " "#expiry = :expiry, #status = :status",
+ "UpdateExpression": "SET #response_data = :response_data, #expiry = :expiry, #status = :status",
}
@pytest.fixture
def expected_params_update_item_with_validation(
- serialized_lambda_response, hashed_idempotency_key, hashed_validation_key
+ serialized_lambda_response,
+ hashed_idempotency_key,
+ hashed_validation_key,
):
return {
"ExpressionAttributeNames": {
@@ -107,12 +112,12 @@ def expected_params_update_item_with_validation(
"#validation_key": "validation",
},
"ExpressionAttributeValues": {
- ":expiry": stub.ANY,
- ":response_data": serialized_lambda_response,
- ":status": "COMPLETED",
- ":validation_key": hashed_validation_key,
+ ":expiry": {"N": stub.ANY},
+ ":response_data": {"S": serialized_lambda_response},
+ ":status": {"S": "COMPLETED"},
+ ":validation_key": {"S": hashed_validation_key},
},
- "Key": {"id": hashed_idempotency_key},
+ "Key": {"id": {"S": hashed_idempotency_key}},
"TableName": "TEST_TABLE",
"UpdateExpression": (
"SET #response_data = :response_data, "
@@ -129,18 +134,23 @@ def expected_params_put_item(hashed_idempotency_key):
"attribute_not_exists(#id) OR #expiry < :now OR "
"(#status = :inprogress AND attribute_exists(#in_progress_expiry) AND #in_progress_expiry < :now_in_millis)"
),
+ "ReturnValuesOnConditionCheckFailure": "ALL_OLD",
"ExpressionAttributeNames": {
"#id": "id",
"#expiry": "expiration",
"#status": "status",
"#in_progress_expiry": "in_progress_expiration",
},
- "ExpressionAttributeValues": {":now": stub.ANY, ":now_in_millis": stub.ANY, ":inprogress": "INPROGRESS"},
+ "ExpressionAttributeValues": {
+ ":now": {"N": stub.ANY},
+ ":now_in_millis": {"N": stub.ANY},
+ ":inprogress": {"S": "INPROGRESS"},
+ },
"Item": {
- "expiration": stub.ANY,
- "id": hashed_idempotency_key,
- "status": "INPROGRESS",
- "in_progress_expiration": stub.ANY,
+ "expiration": {"N": stub.ANY},
+ "in_progress_expiration": {"N": stub.ANY},
+ "id": {"S": hashed_idempotency_key},
+ "status": {"S": "INPROGRESS"},
},
"TableName": "TEST_TABLE",
}
@@ -153,37 +163,50 @@ def expected_params_put_item_with_validation(hashed_idempotency_key, hashed_vali
"attribute_not_exists(#id) OR #expiry < :now OR "
"(#status = :inprogress AND attribute_exists(#in_progress_expiry) AND #in_progress_expiry < :now_in_millis)"
),
+ "ReturnValuesOnConditionCheckFailure": "ALL_OLD",
"ExpressionAttributeNames": {
"#id": "id",
"#expiry": "expiration",
"#status": "status",
"#in_progress_expiry": "in_progress_expiration",
},
- "ExpressionAttributeValues": {":now": stub.ANY, ":now_in_millis": stub.ANY, ":inprogress": "INPROGRESS"},
+ "ExpressionAttributeValues": {
+ ":now": {"N": stub.ANY},
+ ":now_in_millis": {"N": stub.ANY},
+ ":inprogress": {"S": "INPROGRESS"},
+ },
"Item": {
- "expiration": stub.ANY,
- "in_progress_expiration": stub.ANY,
- "id": hashed_idempotency_key,
- "status": "INPROGRESS",
- "validation": hashed_validation_key,
+ "expiration": {"N": stub.ANY},
+ "in_progress_expiration": {"N": stub.ANY},
+ "id": {"S": hashed_idempotency_key},
+ "status": {"S": "INPROGRESS"},
+ "validation": {"S": hashed_validation_key},
},
"TableName": "TEST_TABLE",
}
@pytest.fixture
-def hashed_idempotency_key(lambda_apigw_event, default_jmespath, lambda_context):
+def hashed_idempotency_key(request, lambda_apigw_event, default_jmespath, lambda_context):
compiled_jmespath = jmespath.compile(default_jmespath)
data = compiled_jmespath.search(lambda_apigw_event)
- return "test-func.lambda_handler#" + hash_idempotency_key(data)
+ return (
+ f"test-func.{request.function.__module__}.{request.function.__qualname__}.