")
+ def handler(order_id: str, tenant: Annotated[str, Depends(get_tenant)]):
+ return {"order_id": order_id, "tenant": tenant}
+
+ event = {**API_GW_V2_EVENT}
+ event["rawPath"] = "/orders/abc-123"
+ event["requestContext"] = {
+ **event["requestContext"],
+ "http": {"method": "GET", "path": "/orders/abc-123"},
+ }
+
+ result = app(event, {})
+ assert result["statusCode"] == 200
+ body = json.loads(result["body"])
+ assert body["order_id"] == "abc-123"
+ assert body["tenant"] == "tenant-abc"
+
+
+def test_depends_with_regular_params_and_validation():
+ """Depends() works alongside regular handler parameters with validation."""
+ app = APIGatewayHttpResolver(enable_validation=True)
+
+ def get_greeting() -> str:
+ return "hello"
+
+ @app.post("/my/path")
+ def handler(name: str = "world", greeting: Annotated[str, Depends(get_greeting)] = ""):
+ return {"message": f"{greeting}, {name}!"}
+
+ event = {**API_GW_V2_EVENT, "queryStringParameters": {"name": "Lambda"}}
+ result = app(event, {})
+ assert result["statusCode"] == 200
+ assert json.loads(result["body"]) == {"message": "hello, Lambda!"}
+
+
+class ArbitraryClient:
+ """Stand-in for an arbitrary non-Pydantic type such as a boto3 client."""
+
+ def __init__(self, name: str = "default"):
+ self.name = name
+
+
+def test_depends_with_arbitrary_return_type_and_validation():
+ """A dependency returning a non-Pydantic type must not crash under enable_validation (#8330)."""
+ app = APIGatewayHttpResolver(enable_validation=True)
+
+ def get_client() -> ArbitraryClient:
+ return ArbitraryClient(name="orders")
+
+ @app.get("/items")
+ def handler(client: Annotated[ArbitraryClient, Depends(get_client)]):
+ return {"client": client.name}
+
+ event = {**API_GW_V2_EVENT}
+ event["rawPath"] = "/items"
+ event["requestContext"] = {
+ **event["requestContext"],
+ "http": {"method": "GET", "path": "/items"},
+ }
+
+ result = app(event, {})
+ assert result["statusCode"] == 200
+ assert json.loads(result["body"]) == {"client": "orders"}
+
+
+def test_depends_nested_arbitrary_return_types_and_validation():
+ """A nested dependency chain of non-Pydantic return types must resolve (#8330).
+
+ Mirrors the reported chain: botocore session -> boto3 session -> dynamodb client.
+ """
+ app = APIGatewayHttpResolver(enable_validation=True)
+
+ def get_session() -> ArbitraryClient:
+ return ArbitraryClient(name="session")
+
+ def get_client(session: Annotated[ArbitraryClient, Depends(get_session)]) -> ArbitraryClient:
+ return ArbitraryClient(name=f"client-of-{session.name}")
+
+ @app.get("/items")
+ def handler(client: Annotated[ArbitraryClient, Depends(get_client)]):
+ return {"client": client.name}
+
+ event = {**API_GW_V2_EVENT}
+ event["rawPath"] = "/items"
+ event["requestContext"] = {
+ **event["requestContext"],
+ "http": {"method": "GET", "path": "/items"},
+ }
+
+ result = app(event, {})
+ assert result["statusCode"] == 200
+ assert json.loads(result["body"]) == {"client": "client-of-session"}
+
+
+def test_depends_arbitrary_return_type_excluded_from_openapi_schema():
+ """A non-Pydantic dependency return type must not appear in (or break) the OpenAPI schema (#8330)."""
+ app = APIGatewayHttpResolver(enable_validation=True)
+
+ def get_client() -> ArbitraryClient:
+ return ArbitraryClient()
+
+ @app.get("/items")
+ def handler(client: Annotated[ArbitraryClient, Depends(get_client)]):
+ return {"ok": True}
+
+ # Schema generation itself must not raise, and the dependency must not leak into params/body.
+ schema = app.get_openapi_schema()
+ get_op = schema.paths["/items"].get
+ param_names = [p.name for p in (get_op.parameters or [])]
+
+ assert "client" not in param_names
+ assert get_op.requestBody is None
diff --git a/tests/functional/event_handler/_pydantic/test_http_resolver_pydantic.py b/tests/functional/event_handler/_pydantic/test_http_resolver_pydantic.py
index d31185f3239..e088527e359 100644
--- a/tests/functional/event_handler/_pydantic/test_http_resolver_pydantic.py
+++ b/tests/functional/event_handler/_pydantic/test_http_resolver_pydantic.py
@@ -13,10 +13,6 @@
from aws_lambda_powertools.event_handler.http_resolver import MockLambdaContext
from aws_lambda_powertools.event_handler.openapi.params import Query
-# Suppress warning for all tests
-pytestmark = pytest.mark.filterwarnings("ignore:HttpResolverLocal is intended for local development")
-
-
# =============================================================================
# ASGI Test Helpers
# =============================================================================
@@ -209,7 +205,6 @@ def search(
# =============================================================================
-@pytest.mark.skip("Due to issue #7981.")
@pytest.mark.asyncio
async def test_async_handler_with_validation():
# GIVEN an app with async handler and validation
@@ -241,6 +236,91 @@ async def create_user(user: UserModel) -> UserResponse:
assert body["user"]["name"] == "AsyncUser"
+@pytest.mark.asyncio
+async def test_async_handler_invalid_response_returns_422():
+ # GIVEN an app with async handler and validation
+ app = HttpResolverLocal(enable_validation=True)
+
+ @app.get("/user")
+ async def get_user() -> UserResponse:
+ await asyncio.sleep(0.001)
+ return {"name": "John"} # type: ignore # Missing required fields
+
+ scope = {
+ "type": "http",
+ "method": "GET",
+ "path": "/user",
+ "query_string": b"",
+ "headers": [(b"content-type", b"application/json")],
+ }
+
+ receive = make_asgi_receive()
+ send, captured = make_asgi_send()
+
+ # WHEN called via ASGI interface
+ await app(scope, receive, send)
+
+ # THEN it returns 422 for invalid response
+ assert captured["status_code"] == 422
+
+
+@pytest.mark.asyncio
+async def test_sync_handler_with_validation_via_asgi():
+ # GIVEN an app with a sync handler and validation, called via ASGI
+ app = HttpResolverLocal(enable_validation=True)
+
+ @app.post("/users")
+ def create_user(user: UserModel) -> UserResponse:
+ return UserResponse(id="sync-123", user=user)
+
+ scope = {
+ "type": "http",
+ "method": "POST",
+ "path": "/users",
+ "query_string": b"",
+ "headers": [(b"content-type", b"application/json")],
+ }
+
+ receive = make_asgi_receive(b'{"name": "SyncUser", "age": 30}')
+ send, captured = make_asgi_send()
+
+ # WHEN called via ASGI interface
+ await app(scope, receive, send)
+
+ # THEN validation works with sync handler
+ assert captured["status_code"] == 200
+ body = json.loads(captured["body"])
+ assert body["id"] == "sync-123"
+ assert body["user"]["name"] == "SyncUser"
+
+
+@pytest.mark.asyncio
+async def test_sync_handler_invalid_response_returns_422_via_asgi():
+ # GIVEN an app with a sync handler and validation, called via ASGI
+ app = HttpResolverLocal(enable_validation=True)
+
+ @app.get("/user")
+ def get_user() -> UserResponse:
+ return {"name": "John"} # type: ignore # Missing required fields
+
+ scope = {
+ "type": "http",
+ "method": "GET",
+ "path": "/user",
+ "query_string": b"",
+ "headers": [(b"content-type", b"application/json")],
+ }
+
+ receive = make_asgi_receive()
+ send, captured = make_asgi_send()
+
+ # WHEN called via ASGI interface
+ await app(scope, receive, send)
+
+ # THEN it returns 422 for invalid response
+ assert captured["status_code"] == 422
+
+
# =============================================================================
# OpenAPI Tests
# =============================================================================
diff --git a/tests/functional/event_handler/_pydantic/test_openapi_merge.py b/tests/functional/event_handler/_pydantic/test_openapi_merge.py
index b4dc1d70232..88834667727 100644
--- a/tests/functional/event_handler/_pydantic/test_openapi_merge.py
+++ b/tests/functional/event_handler/_pydantic/test_openapi_merge.py
@@ -367,3 +367,52 @@ def test_openapi_merge_schema_is_cached():
# AND paths should not be duplicated
assert len([p for p in schema1["paths"] if p == "/users"]) == 1
+
+
+def test_openapi_merge_shared_resolver_pattern():
+ # GIVEN a shared resolver pattern where:
+ # - resolver.py defines the resolver
+ # - products_routes.py and categories_routes.py import it and register routes
+ merge = OpenAPIMerge(title="Shared Resolver API", version="1.0.0")
+
+ # WHEN discovering with project_root set to allow absolute imports
+ shared_path = MERGE_HANDLERS_PATH / "shared"
+ project_root = Path(__file__).parent.parent.parent.parent.parent # repo root
+
+ files = merge.discover(
+ path=shared_path,
+ pattern="resolver.py",
+ project_root=project_root,
+ )
+
+ # THEN it should find the resolver file
+ assert len(files) == 1
+ assert files[0].name == "resolver.py"
+
+ # AND it should find dependent files that import the resolver
+ dependent = merge.dependent_files.get(files[0], [])
+ dependent_names = [f.name for f in dependent]
+ assert "products_routes.py" in dependent_names
+ assert "categories_routes.py" in dependent_names
+
+ # AND the merged schema should include routes from all dependent files
+ schema = merge.get_openapi_schema()
+ assert "/products" in schema["paths"]
+ assert "/products/{product_id}" in schema["paths"]
+ assert "/categories" in schema["paths"]
+
+
+def test_openapi_merge_discover_type_annotated_resolver():
+ # GIVEN an OpenAPIMerge instance
+ merge = OpenAPIMerge(title="Typed API", version="1.0.0")
+
+ # WHEN discovering a handler with a type-annotated resolver (app: Resolver = Resolver())
+ merge.discover(
+ path=MERGE_HANDLERS_PATH,
+ pattern="**/typed_handler.py",
+ resolver_name="app",
+ )
+
+ # THEN it should find the resolver and include its routes in the schema
+ schema = merge.get_openapi_schema()
+ assert "/products" in schema["paths"]
diff --git a/tests/functional/event_handler/_pydantic/test_openapi_params.py b/tests/functional/event_handler/_pydantic/test_openapi_params.py
index 18087a228d1..b9426bf4da7 100644
--- a/tests/functional/event_handler/_pydantic/test_openapi_params.py
+++ b/tests/functional/event_handler/_pydantic/test_openapi_params.py
@@ -1,7 +1,7 @@
import json
from dataclasses import dataclass
from datetime import datetime
-from typing import List, Optional, Tuple
+from typing import List, Literal, Tuple
import pytest
from pydantic import BaseModel, Field
@@ -34,7 +34,7 @@ def test_openapi_pydantic_query_params():
class QueryParams(BaseModel):
limit: int = Field(default=10, ge=1, le=100, description="Number of items to return")
offset: int = Field(default=0, ge=0, description="Number of items to skip")
- search: Optional[str] = Field(default=None, description="Search term")
+ search: str | None = Field(default=None, description="Search term")
@app.get("/search")
def search_handler(params: Annotated[QueryParams, Query()]):
@@ -82,7 +82,7 @@ def test_openapi_pydantic_header_params():
class HeaderParams(BaseModel):
authorization: str = Field(description="Authorization token")
user_agent: str = Field(default="PowerTools/1.0", description="User agent")
- language: Optional[str] = Field(default=None, alias="accept-language", description="Language preference")
+ language: str | None = Field(default=None, alias="accept-language", description="Language preference")
@app.get("/protected")
def protected_handler(headers: Annotated[HeaderParams, Header()]):
@@ -915,6 +915,24 @@ def mixed_body_endpoint(user_data: Annotated[UserData, Body(media_type="applicat
assert "application/json" in request_body.content
+def test_openapi_excludes_content_type_header_parameter():
+ """Content-Type is described by requestBody content, not an OpenAPI header parameter."""
+ app = APIGatewayRestResolver(enable_validation=True)
+
+ @app.patch("/json-patch")
+ def json_patch(
+ operations: Annotated[list[dict], Body(media_type="application/json-patch+json")],
+ content_type: Annotated[Literal["application/json-patch+json"], Header(alias="Content-Type")],
+ ):
+ return {"status": "updated"}
+
+ schema = app.get_openapi_schema()
+ patch_op = schema.paths["/json-patch"].patch
+
+ assert "application/json-patch+json" in patch_op.requestBody.content
+ assert all(parameter.name.lower() != "content-type" for parameter in patch_op.parameters or [])
+
+
def test_openapi_form_parameter_edge_cases():
"""Test Form parameters with various edge cases."""
@@ -923,7 +941,7 @@ def test_openapi_form_parameter_edge_cases():
@app.post("/form-edge-cases")
def form_edge_cases(
required_field: Annotated[str, Form(description="Required field")],
- optional_field: Annotated[Optional[str], Form(description="Optional field")] = None,
+ optional_field: Annotated[str | None, Form(description="Optional field")] = None,
field_with_default: Annotated[str, Form(description="Field with default")] = "default_value",
):
return {"required": required_field, "optional": optional_field, "default": field_with_default}
@@ -986,7 +1004,7 @@ def get_items(params: Annotated[QueryParams, Query()]):
def test_openapi_pydantic_header_with_alias():
- """Test that Pydantic field aliases work correctly in Header parameters"""
+ """Test that Pydantic header aliases are emitted, except Content-Type."""
app = APIGatewayRestResolver()
class HeaderParams(BaseModel):
@@ -1003,7 +1021,7 @@ def test_handler(headers: Annotated[HeaderParams, Header()]):
# Check that aliases are used as parameter names
param_names = [param.name for param in get_operation.parameters]
- assert "content-type" in param_names
+ assert "content-type" not in param_names
assert "user-agent" in param_names
assert "content_type" not in param_names # Original field name should not be used
assert "user_agent" not in param_names
@@ -1016,7 +1034,7 @@ def test_openapi_pydantic_required_vs_optional():
class QueryParams(BaseModel):
required_field: str = Field(description="Required field")
optional_with_default: str = Field(default="default", description="Optional with default")
- optional_nullable: Optional[str] = Field(default=None, description="Optional nullable")
+ optional_nullable: str | None = Field(default=None, description="Optional nullable")
@app.get("/test")
def test_handler(params: Annotated[QueryParams, Query()]):
diff --git a/tests/functional/event_handler/_pydantic/test_openapi_responses.py b/tests/functional/event_handler/_pydantic/test_openapi_responses.py
index 71c7d186cbe..c00b4e00462 100644
--- a/tests/functional/event_handler/_pydantic/test_openapi_responses.py
+++ b/tests/functional/event_handler/_pydantic/test_openapi_responses.py
@@ -1,5 +1,5 @@
from secrets import randbelow
-from typing import Optional, Union
+from typing import Union
from pydantic import BaseModel
@@ -232,7 +232,7 @@ def handler():
schema = app.get_openapi_schema()
responses = schema.paths["/"].get.responses
- # THE the schema should include a 200 successful response
+ # The schema should include a 200 successful response
# but not a 422 validation error response since validation is disabled
assert 200 in responses.keys()
assert responses[200].description == "Successful Response"
@@ -325,7 +325,7 @@ def test_openapi_response_examples_preserved_with_model():
class UserResponse(BaseModel):
id: int
name: str
- email: Optional[str] = None
+ email: str | None = None
@app.get(
"/",
@@ -370,3 +370,89 @@ def handler() -> UserResponse:
assert "example2" in examples
assert examples["example2"].summary == "Example 2"
assert examples["example2"].value["id"] == 2
+
+
+def test_openapi_custom_status_code():
+ # GIVEN a route with a custom status_code
+ app = APIGatewayRestResolver(enable_validation=True)
+
+ class Item(BaseModel):
+ name: str
+
+ @app.post("/items", status_code=201)
+ def create_item() -> Item:
+ return Item(name="test")
+
+ # WHEN we retrieve the OpenAPI schema
+ schema = app.get_openapi_schema()
+ responses = schema.paths["/items"].post.responses
+
+ # THEN the schema should use 201 as the success response code instead of 200
+ assert 201 in responses
+ assert responses[201].description == "Successful Response"
+ assert 200 not in responses
+
+
+def test_openapi_custom_status_code_with_description():
+ # GIVEN a route with a custom status_code and response_description
+ app = APIGatewayRestResolver(enable_validation=True)
+
+ @app.post("/items", status_code=201, response_description="Item created")
+ def create_item():
+ return {"name": "test"}
+
+ # WHEN we retrieve the OpenAPI schema
+ schema = app.get_openapi_schema()
+ responses = schema.paths["/items"].post.responses
+
+ # THEN the schema should use 201 with the custom description
+ assert 201 in responses
+ assert responses[201].description == "Item created"
+ assert 200 not in responses
+
+
+def test_openapi_default_status_code():
+ # GIVEN a route without a custom status_code
+ app = APIGatewayRestResolver(enable_validation=True)
+
+ @app.get("/items")
+ def get_items():
+ return {"items": []}
+
+ # WHEN we retrieve the OpenAPI schema
+ schema = app.get_openapi_schema()
+ responses = schema.paths["/items"].get.responses
+
+ # THEN the schema should default to 200
+ assert 200 in responses
+ assert responses[200].description == "Successful Response"
+
+
+def test_openapi_custom_status_code_all_methods():
+ # GIVEN routes with custom status_code on different HTTP methods
+ app = APIGatewayRestResolver(enable_validation=True)
+
+ @app.post("/items", status_code=201)
+ def create():
+ return {}
+
+ @app.put("/items", status_code=204)
+ def update():
+ return {}
+
+ @app.delete("/items", status_code=204)
+ def delete():
+ return {}
+
+ @app.patch("/items", status_code=202)
+ def patch():
+ return {}
+
+ # WHEN we retrieve the OpenAPI schema
+ schema = app.get_openapi_schema()
+
+ # THEN each method should have the correct custom status code
+ assert 201 in schema.paths["/items"].post.responses
+ assert 204 in schema.paths["/items"].put.responses
+ assert 204 in schema.paths["/items"].delete.responses
+ assert 202 in schema.paths["/items"].patch.responses
diff --git a/tests/functional/event_handler/_pydantic/test_openapi_schema_pydantic_v2.py b/tests/functional/event_handler/_pydantic/test_openapi_schema_pydantic_v2.py
index 0df8f6a22c5..6f4b89e47cc 100644
--- a/tests/functional/event_handler/_pydantic/test_openapi_schema_pydantic_v2.py
+++ b/tests/functional/event_handler/_pydantic/test_openapi_schema_pydantic_v2.py
@@ -1,9 +1,9 @@
import json
import warnings
-from typing import Literal, Optional
+from typing import Literal
import pytest
-from pydantic import BaseModel, Field
+from pydantic import BaseModel, Field, computed_field
from typing_extensions import Annotated
from aws_lambda_powertools.event_handler import APIGatewayRestResolver
@@ -52,7 +52,7 @@ def test_openapi_3_1_complex_handler(openapi31_schema):
# GIVEN a complex pydantic model
class TodoAttributes(BaseModel):
userId: int
- id_: Optional[int] = Field(alias="id", default=None)
+ id_: int | None = Field(alias="id", default=None)
title: str
completed: bool
@@ -110,3 +110,79 @@ def create_todo(todo: TodoEnvelope): ...
# THEN the schema should be valid
assert openapi31_schema(schema)
+
+
+@pytest.mark.usefixtures("pydanticv2_only")
+def test_openapi_schema_includes_computed_field():
+ # GIVEN a model with a computed_field
+ class User(BaseModel):
+ first_name: str
+ last_name: str
+
+ @computed_field
+ @property
+ def full_name(self) -> str:
+ return f"{self.first_name} {self.last_name}"
+
+ # GIVEN APIGatewayRestResolver with a handler returning that model
+ app = APIGatewayRestResolver(enable_validation=True)
+
+ @app.get("/user")
+ def get_user() -> User:
+ return User(first_name="John", last_name="Doe")
+
+ # WHEN we get the schema
+ schema = json.loads(app.get_openapi_json_schema())
+
+ # THEN the computed_field should appear in the response schema
+ user_schema = schema["components"]["schemas"]["User"]
+ assert "full_name" in user_schema["properties"]
+ assert user_schema["properties"]["full_name"]["type"] == "string"
+ assert user_schema["properties"]["full_name"].get("readOnly") is True
+
+
+@pytest.mark.usefixtures("pydanticv2_only")
+def test_openapi_schema_computed_field_not_in_request_body():
+ # GIVEN a model with a computed_field used as both request and response
+ class Item(BaseModel):
+ price: float
+ quantity: int
+
+ @computed_field
+ @property
+ def total(self) -> float:
+ return self.price * self.quantity
+
+ # GIVEN APIGatewayRestResolver with handlers using the model
+ app = APIGatewayRestResolver(enable_validation=True)
+
+ @app.post("/items")
+ def create_item(item: Item) -> Item:
+ return item
+
+ # WHEN we get the schema
+ schema = json.loads(app.get_openapi_json_schema())
+
+ # THEN the request body schema should NOT include computed_field
+ request_body = schema["paths"]["/items"]["post"]["requestBody"]
+ request_ref = request_body["content"]["application/json"]["schema"]["$ref"]
+ request_schema_name = request_ref.split("/")[-1]
+
+ # THEN the response schema SHOULD include computed_field
+ response_ref = schema["paths"]["/items"]["post"]["responses"]["200"]["content"]["application/json"]["schema"][
+ "$ref"
+ ]
+ response_schema_name = response_ref.split("/")[-1]
+
+ # When input/output schemas are separate, we expect different schema names
+ # When they share a schema, computed_field should be present
+ if request_schema_name == response_schema_name:
+ # Shared schema - computed_field should be present (serialization mode wins)
+ item_schema = schema["components"]["schemas"][response_schema_name]
+ assert "total" in item_schema["properties"]
+ else:
+ # Separate schemas
+ input_schema = schema["components"]["schemas"][request_schema_name]
+ output_schema = schema["components"]["schemas"][response_schema_name]
+ assert "total" not in input_schema["properties"]
+ assert "total" in output_schema["properties"]
diff --git a/tests/functional/event_handler/_pydantic/test_openapi_serialization.py b/tests/functional/event_handler/_pydantic/test_openapi_serialization.py
index ef5c8ddd938..9bad2c61422 100644
--- a/tests/functional/event_handler/_pydantic/test_openapi_serialization.py
+++ b/tests/functional/event_handler/_pydantic/test_openapi_serialization.py
@@ -1,6 +1,6 @@
import json
from dataclasses import dataclass
-from typing import Dict, Optional, Set
+from typing import Dict, Set
import pytest
from pydantic import BaseModel
@@ -81,7 +81,7 @@ class Model(BaseModel):
age: int
@app.get("/valid_optional")
- def handler_valid_optional() -> Optional[Model]:
+ def handler_valid_optional() -> Model | None:
return Model(name="John", age=30)
# WHEN returning a valid model for an Optional type
diff --git a/tests/functional/event_handler/_pydantic/test_openapi_validation_middleware.py b/tests/functional/event_handler/_pydantic/test_openapi_validation_middleware.py
index 93baef283ba..ec14c14fa08 100644
--- a/tests/functional/event_handler/_pydantic/test_openapi_validation_middleware.py
+++ b/tests/functional/event_handler/_pydantic/test_openapi_validation_middleware.py
@@ -1,13 +1,23 @@
import base64
import datetime
import json
+import warnings
from dataclasses import dataclass
from enum import Enum
from pathlib import PurePath
-from typing import Any, Dict, List, Literal, Optional, Tuple, Union
+from typing import Any, Dict, List, Literal, Tuple, Union
import pytest
-from pydantic import AfterValidator, Base64UrlStr, BaseModel, ConfigDict, Field, StringConstraints, alias_generators
+from pydantic import (
+ AfterValidator,
+ Base64UrlStr,
+ BaseModel,
+ ConfigDict,
+ Field,
+ RootModel,
+ StringConstraints,
+ alias_generators,
+)
from typing_extensions import Annotated
from aws_lambda_powertools.event_handler import (
@@ -20,7 +30,7 @@
VPCLatticeV2Resolver,
)
from aws_lambda_powertools.event_handler.openapi.exceptions import ResponseValidationError
-from aws_lambda_powertools.event_handler.openapi.params import Body, Form, Header, Query
+from aws_lambda_powertools.event_handler.openapi.params import Body, Form, Header, Path, Query
from tests.functional.utils import load_event
@@ -49,6 +59,27 @@ def handler(user_id: int):
assert any(text in result["body"] for text in ["type_error.integer", "int_parsing"])
+def test_content_type_header_validation_remains_enabled(gw_event):
+ """Content-Type header validation remains available when it is omitted from the OpenAPI schema."""
+ app = APIGatewayRestResolver(enable_validation=True)
+
+ @app.patch("/json-patch")
+ def json_patch(
+ operations: Annotated[list[dict], Body(media_type="application/json-patch+json")],
+ content_type: Annotated[Literal["application/json-patch+json"], Header(alias="Content-Type")],
+ ):
+ return {"status": "updated"}
+
+ gw_event["httpMethod"] = "PATCH"
+ gw_event["path"] = "/json-patch"
+ gw_event["headers"]["Content-Type"] = "application/json"
+ gw_event["body"] = '[{"op": "replace"}]'
+
+ result = app(gw_event, {})
+
+ assert result["statusCode"] == 422
+
+
def test_validate_pydantic_query_params(gw_event):
"""Test that Pydantic models in Query parameters are validated correctly"""
@@ -57,7 +88,7 @@ def test_validate_pydantic_query_params(gw_event):
class QueryParams(BaseModel):
limit: int = Field(default=10, ge=1, le=100, description="Number of items")
- search: Optional[str] = Field(default=None, description="Search term")
+ search: str | None = Field(default=None, description="Search term")
@app.get("/search")
def search_handler(params: Annotated[QueryParams, Query()]):
@@ -1746,7 +1777,7 @@ class Model(BaseModel):
age: int
@app.get("/none_allowed")
- def handler_none_allowed() -> Optional[Model]:
+ def handler_none_allowed() -> Model | None:
return None
# WHEN returning None for an Optional type
@@ -2644,6 +2675,77 @@ def create_action(action: Annotated[action_type, Body()]):
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"""
@@ -2833,3 +2935,1423 @@ def handler(query_dt: datetime.datetime):
# 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_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/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_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
index 40fb3d20c64..4665812e64a 100644
--- a/tests/functional/event_handler/required_dependencies/test_http_resolver.py
+++ b/tests/functional/event_handler/required_dependencies/test_http_resolver.py
@@ -1,4 +1,4 @@
-"""Tests for HttpResolverLocal - ASGI-compatible HTTP resolver for local development."""
+"""Tests for HttpResolverLocal - ASGI-compatible HTTP resolver."""
from __future__ import annotations
@@ -11,10 +11,6 @@
from aws_lambda_powertools.event_handler import HttpResolverLocal, Response
from aws_lambda_powertools.event_handler.http_resolver import MockLambdaContext
-# Suppress warning for all tests
-pytestmark = pytest.mark.filterwarnings("ignore:HttpResolverLocal is intended for local development")
-
-
# =============================================================================
# ASGI Test Helpers
# =============================================================================
@@ -1242,3 +1238,219 @@ def hello():
# 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/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/idempotency/_boto3/test_idempotency.py b/tests/functional/idempotency/_boto3/test_idempotency.py
index e5916dba0fa..56b317a9b5a 100644
--- a/tests/functional/idempotency/_boto3/test_idempotency.py
+++ b/tests/functional/idempotency/_boto3/test_idempotency.py
@@ -2,7 +2,7 @@
import dataclasses
import datetime
import warnings
-from typing import Any, Optional
+from typing import Any
from unittest.mock import MagicMock, Mock
import jmespath
@@ -1046,6 +1046,10 @@ def test_is_missing_idempotency_key():
assert BasePersistenceLayer.is_missing_idempotency_key((None, None))
# GIVEN a dict of Nones THEN is_missing_idempotency_key is True
assert BasePersistenceLayer.is_missing_idempotency_key({None: None})
+ # GIVEN a dict with all-None values THEN is_missing_idempotency_key is True
+ assert BasePersistenceLayer.is_missing_idempotency_key({"user": None, "order": None})
+ # GIVEN a dict with a non-None value THEN is_missing_idempotency_key is False
+ assert BasePersistenceLayer.is_missing_idempotency_key({"user": "abc"}) is False
# GIVEN True THEN is_missing_idempotency_key is False
assert BasePersistenceLayer.is_missing_idempotency_key(True) is False
@@ -1114,6 +1118,19 @@ def test_raise_on_no_idempotency_key(
assert "No data found to create a hashed idempotency_key" in str(excinfo.value)
+def test_raise_on_no_idempotency_key_for_dict_jmespath(persistence_store: DynamoDBPersistenceLayer):
+ # GIVEN a dict multi-select expression whose values are missing
+ idempotency_config = IdempotencyConfig(
+ event_key_jmespath="{user: headers.user_id, order: body.order_id}",
+ raise_on_no_idempotency_key=True,
+ )
+ persistence_store.configure(idempotency_config)
+
+ # WHEN extracting the idempotency key THEN raise IdempotencyKeyError
+ with pytest.raises(IdempotencyKeyError, match="No data found to create a hashed idempotency_key"):
+ persistence_store._get_hashed_idempotency_key({"headers": {}, "body": {}})
+
+
@pytest.mark.parametrize(
"idempotency_config",
[
@@ -2086,7 +2103,7 @@ class PaymentOutput:
config=config,
output_serializer=output_serializer,
)
- def collect_payment(payment: PaymentInput) -> Optional[PaymentOutput]:
+ def collect_payment(payment: PaymentInput) -> PaymentOutput | None:
return PaymentOutput(**dataclasses.asdict(payment))
# WHEN
diff --git a/tests/functional/idempotency/_pydantic/test_idempotency_pydantic_json_serialization.py b/tests/functional/idempotency/_pydantic/test_idempotency_pydantic_json_serialization.py
new file mode 100644
index 00000000000..624e4239e98
--- /dev/null
+++ b/tests/functional/idempotency/_pydantic/test_idempotency_pydantic_json_serialization.py
@@ -0,0 +1,185 @@
+"""
+Test for issue #8065: @idempotent_function fails with non-JSON-serializable types in Pydantic models
+
+Bug: _prepare_data() calls model_dump() without mode="json", which doesn't
+serialize UUIDs, dates, datetimes, Decimals, and Paths to JSON-compatible strings.
+"""
+
+from datetime import date, datetime
+from decimal import Decimal
+from pathlib import PurePosixPath
+from uuid import UUID
+
+from pydantic import BaseModel
+
+from aws_lambda_powertools.utilities.idempotency import (
+ IdempotencyConfig,
+ idempotent_function,
+)
+from aws_lambda_powertools.utilities.idempotency.persistence.base import (
+ BasePersistenceLayer,
+ DataRecord,
+)
+from tests.functional.idempotency.utils import hash_idempotency_key
+
+TESTS_MODULE_PREFIX = "test-func.tests.functional.idempotency._pydantic.test_idempotency_pydantic_json_serialization"
+
+
+class MockPersistenceLayer(BasePersistenceLayer):
+ def __init__(self, expected_idempotency_key: str):
+ self.expected_idempotency_key = expected_idempotency_key
+ super().__init__()
+
+ def _put_record(self, data_record: DataRecord) -> None:
+ assert data_record.idempotency_key == self.expected_idempotency_key
+
+ def _update_record(self, data_record: DataRecord) -> None:
+ assert data_record.idempotency_key == self.expected_idempotency_key
+
+ def _get_record(self, idempotency_key) -> DataRecord: ...
+
+ def _delete_record(self, data_record: DataRecord) -> None: ...
+
+
+# --- Models ---
+
+
+class PaymentWithUUID(BaseModel):
+ payment_id: UUID
+ customer_id: str
+
+
+class EventWithDate(BaseModel):
+ event_id: str
+ event_date: date
+
+
+class OrderWithDatetime(BaseModel):
+ order_id: str
+ created_at: datetime
+
+
+class InvoiceWithDecimal(BaseModel):
+ invoice_id: str
+ amount: Decimal
+
+
+class ConfigWithPath(BaseModel):
+ config_id: str
+ file_path: PurePosixPath
+
+
+def test_idempotent_function_with_uuid():
+ # GIVEN
+ config = IdempotencyConfig(use_local_cache=True)
+ payment_uuid = UUID("12345678-1234-5678-1234-567812345678")
+ mock_event = {"payment_id": str(payment_uuid), "customer_id": "c-456"}
+ idempotency_key = f"{TESTS_MODULE_PREFIX}.test_idempotent_function_with_uuid..collect_payment#{hash_idempotency_key(mock_event)}" # noqa E501
+ persistence_layer = MockPersistenceLayer(expected_idempotency_key=idempotency_key)
+
+ @idempotent_function(
+ data_keyword_argument="payment",
+ persistence_store=persistence_layer,
+ config=config,
+ )
+ def collect_payment(payment: PaymentWithUUID) -> dict:
+ return {"status": "ok"}
+
+ # WHEN
+ payment = PaymentWithUUID(payment_id=payment_uuid, customer_id="c-456")
+ result = collect_payment(payment=payment)
+
+ # THEN
+ assert result == {"status": "ok"}
+
+
+def test_idempotent_function_with_date():
+ # GIVEN
+ config = IdempotencyConfig(use_local_cache=True)
+ mock_event = {"event_id": "evt-001", "event_date": "2024-03-20"}
+ idempotency_key = f"{TESTS_MODULE_PREFIX}.test_idempotent_function_with_date..process_event#{hash_idempotency_key(mock_event)}" # noqa E501
+ persistence_layer = MockPersistenceLayer(expected_idempotency_key=idempotency_key)
+
+ @idempotent_function(
+ data_keyword_argument="event",
+ persistence_store=persistence_layer,
+ config=config,
+ )
+ def process_event(event: EventWithDate) -> dict:
+ return {"status": "ok"}
+
+ # WHEN
+ event = EventWithDate(event_id="evt-001", event_date=date(2024, 3, 20))
+ result = process_event(event=event)
+
+ # THEN
+ assert result == {"status": "ok"}
+
+
+def test_idempotent_function_with_datetime():
+ # GIVEN
+ config = IdempotencyConfig(use_local_cache=True)
+ mock_event = {"order_id": "ord-001", "created_at": "2024-03-20T14:30:00"}
+ idempotency_key = f"{TESTS_MODULE_PREFIX}.test_idempotent_function_with_datetime..process_order#{hash_idempotency_key(mock_event)}" # noqa E501
+ persistence_layer = MockPersistenceLayer(expected_idempotency_key=idempotency_key)
+
+ @idempotent_function(
+ data_keyword_argument="order",
+ persistence_store=persistence_layer,
+ config=config,
+ )
+ def process_order(order: OrderWithDatetime) -> dict:
+ return {"status": "ok"}
+
+ # WHEN
+ order = OrderWithDatetime(order_id="ord-001", created_at=datetime(2024, 3, 20, 14, 30, 0))
+ result = process_order(order=order)
+
+ # THEN
+ assert result == {"status": "ok"}
+
+
+def test_idempotent_function_with_decimal():
+ # GIVEN
+ config = IdempotencyConfig(use_local_cache=True)
+ mock_event = {"invoice_id": "inv-001", "amount": "199.99"}
+ idempotency_key = f"{TESTS_MODULE_PREFIX}.test_idempotent_function_with_decimal..process_invoice#{hash_idempotency_key(mock_event)}" # noqa E501
+ persistence_layer = MockPersistenceLayer(expected_idempotency_key=idempotency_key)
+
+ @idempotent_function(
+ data_keyword_argument="invoice",
+ persistence_store=persistence_layer,
+ config=config,
+ )
+ def process_invoice(invoice: InvoiceWithDecimal) -> dict:
+ return {"status": "ok"}
+
+ # WHEN
+ invoice = InvoiceWithDecimal(invoice_id="inv-001", amount=Decimal("199.99"))
+ result = process_invoice(invoice=invoice)
+
+ # THEN
+ assert result == {"status": "ok"}
+
+
+def test_idempotent_function_with_path():
+ # GIVEN
+ config = IdempotencyConfig(use_local_cache=True)
+ mock_event = {"config_id": "cfg-001", "file_path": "/etc/app/config.yaml"}
+ idempotency_key = f"{TESTS_MODULE_PREFIX}.test_idempotent_function_with_path..process_config#{hash_idempotency_key(mock_event)}" # noqa E501
+ persistence_layer = MockPersistenceLayer(expected_idempotency_key=idempotency_key)
+
+ @idempotent_function(
+ data_keyword_argument="config",
+ persistence_store=persistence_layer,
+ config=config,
+ )
+ def process_config(config: ConfigWithPath) -> dict:
+ return {"status": "ok"}
+
+ # WHEN
+ cfg = ConfigWithPath(config_id="cfg-001", file_path=PurePosixPath("/etc/app/config.yaml"))
+ result = process_config(config=cfg)
+
+ # THEN
+ assert result == {"status": "ok"}
diff --git a/tests/functional/idempotency/_pydantic/test_idempotency_with_pydantic.py b/tests/functional/idempotency/_pydantic/test_idempotency_with_pydantic.py
index f8e3debbc30..b5decebafbe 100644
--- a/tests/functional/idempotency/_pydantic/test_idempotency_with_pydantic.py
+++ b/tests/functional/idempotency/_pydantic/test_idempotency_with_pydantic.py
@@ -1,5 +1,3 @@
-from typing import Optional
-
import pytest
from pydantic import BaseModel
@@ -252,7 +250,7 @@ class PaymentOutput(BaseModel):
config=config,
output_serializer=output_serializer,
)
- def collect_payment(payment: PaymentInput) -> Optional[PaymentOutput]:
+ def collect_payment(payment: PaymentInput) -> PaymentOutput | None:
return PaymentOutput(**payment.dict())
# WHEN
diff --git a/tests/functional/idempotency/_redis/test_redis_layer.py b/tests/functional/idempotency/_redis/test_redis_layer.py
index c2a0976b0ab..6adb97a64a4 100644
--- a/tests/functional/idempotency/_redis/test_redis_layer.py
+++ b/tests/functional/idempotency/_redis/test_redis_layer.py
@@ -3,6 +3,7 @@
import datetime
import json
import time as t
+from threading import Event, Lock as ThreadLock, Thread
from unittest import mock
import pytest
@@ -26,6 +27,7 @@
STATUS_CONSTANTS,
DataRecord,
)
+from aws_lambda_powertools.utilities.idempotency.persistence.cache import CachePersistenceLayer
from aws_lambda_powertools.utilities.idempotency.persistence.redis import (
RedisCachePersistenceLayer,
)
@@ -198,6 +200,16 @@ def valid_record():
)
+@pytest.fixture
+def in_progress_record_missing_expiry():
+ return DataRecord(
+ idempotency_key="test_orphan_key",
+ status=STATUS_CONSTANTS["INPROGRESS"],
+ expiry_timestamp=int(datetime.datetime.now().timestamp()) + 60,
+ in_progress_expiry_timestamp=None,
+ )
+
+
@mock.patch("aws_lambda_powertools.utilities.idempotency.persistence.redis.redis", MockRedis())
def test_redis_connection_standalone():
# when RedisCachePersistenceLayer is init with the following params
@@ -303,6 +315,73 @@ def test_redis_orphan_record_lock(orphan_record, valid_record):
)
+@mock.patch("aws_lambda_powertools.utilities.idempotency.persistence.redis.redis", MockRedis())
+def test_redis_in_progress_record_missing_expiry_is_not_treated_as_orphan(in_progress_record_missing_expiry):
+ layer = RedisCachePersistenceLayer(host="host")
+ layer._put_in_progress_record(in_progress_record_missing_expiry)
+
+ contender = DataRecord(
+ idempotency_key=in_progress_record_missing_expiry.idempotency_key,
+ status=STATUS_CONSTANTS["INPROGRESS"],
+ expiry_timestamp=in_progress_record_missing_expiry.expiry_timestamp + 60,
+ in_progress_expiry_timestamp=None,
+ )
+
+ with pytest.raises(IdempotencyItemAlreadyExistsError):
+ layer._put_in_progress_record(contender)
+
+ stored_record = layer._get_record(in_progress_record_missing_expiry.idempotency_key)
+ assert stored_record.status == STATUS_CONSTANTS["INPROGRESS"]
+ assert stored_record.expiry_timestamp == in_progress_record_missing_expiry.expiry_timestamp
+
+
+@pytest.mark.filterwarnings("ignore:Couldn't determine the remaining time left")
+def test_idempotent_function_blocks_concurrent_invocation_without_lambda_context():
+ layer = CachePersistenceLayer(client=MockRedis(host="localhost"))
+ first_invocation_started = Event()
+ release_first_invocation = Event()
+ execution_lock = ThreadLock()
+ execution_count = 0
+ first_result = []
+ first_errors = []
+
+ @idempotent_function(data_keyword_argument="record", persistence_store=layer)
+ def process(record):
+ nonlocal execution_count
+ with execution_lock:
+ execution_count += 1
+ current_execution = execution_count
+
+ if current_execution == 1:
+ first_invocation_started.set()
+ if not release_first_invocation.wait(timeout=5):
+ raise TimeoutError("Timed out waiting to release the first invocation")
+
+ return {"execution": current_execution}
+
+ def invoke_first():
+ try:
+ first_result.append(process(record={"id": "same"}))
+ except Exception as exc:
+ first_errors.append(exc)
+
+ first_invocation = Thread(target=invoke_first)
+ first_invocation.start()
+ assert first_invocation_started.wait(timeout=2)
+
+ try:
+ with pytest.raises(IdempotencyAlreadyInProgressError):
+ process(record={"id": "same"})
+ finally:
+ release_first_invocation.set()
+ first_invocation.join(timeout=5)
+
+ assert not first_invocation.is_alive()
+ assert first_errors == []
+ assert first_result == [{"execution": 1}]
+ assert execution_count == 1
+
+
@mock.patch("aws_lambda_powertools.utilities.idempotency.persistence.redis.redis", MockRedis())
def test_redis_error_in_progress(valid_record):
layer = RedisCachePersistenceLayer(host="host", mode="standalone")
@@ -330,6 +409,25 @@ def test_item_to_datarecord_conversion(valid_record):
assert record.in_progress_expiry_timestamp == item[layer.in_progress_expiry_attr]
+def test_item_to_datarecord_conversion_missing_optional_attributes(persistence_store_standalone_redis):
+ """
+ When data_attr or validation_key_attr is missing from Redis,
+ response_data and payload_hash should be empty string, not the string "None".
+ Regression test for: https://github.com/aws-powertools/powertools-lambda-python/issues/8090
+ """
+ idempotency_key = "test-func#abc123"
+ item = {
+ persistence_store_standalone_redis.status_attr: "COMPLETED",
+ persistence_store_standalone_redis.expiry_attr: 9999999999,
+ # data_attr and validation_key_attr intentionally absent
+ }
+
+ record = persistence_store_standalone_redis._item_to_data_record(idempotency_key, item)
+
+ assert record.response_data == ""
+ assert record.payload_hash == ""
+
+
def test_idempotent_function_and_lambda_handler_redis_basic(
persistence_store_standalone_redis: RedisCachePersistenceLayer,
lambda_context,
diff --git a/tests/functional/idempotency/utils.py b/tests/functional/idempotency/utils.py
index 2e1ee4ab821..c6b3ee9b1a0 100644
--- a/tests/functional/idempotency/utils.py
+++ b/tests/functional/idempotency/utils.py
@@ -1,6 +1,6 @@
import hashlib
import json
-from typing import Any, Dict, Optional
+from typing import Any, Dict
from botocore import stub
from pytest import FixtureRequest
@@ -88,7 +88,7 @@ def build_idempotency_put_item_response_stub(
expiration: int,
status: str,
request: FixtureRequest,
- validation_data: Optional[Any],
+ validation_data: Any | None,
):
response = {
"Item": {
diff --git a/tests/functional/metadata/__init__.py b/tests/functional/metadata/__init__.py
new file mode 100644
index 00000000000..8b137891791
--- /dev/null
+++ b/tests/functional/metadata/__init__.py
@@ -0,0 +1 @@
+
diff --git a/tests/functional/metadata/test_lambda_metadata.py b/tests/functional/metadata/test_lambda_metadata.py
new file mode 100644
index 00000000000..ee8eafe5047
--- /dev/null
+++ b/tests/functional/metadata/test_lambda_metadata.py
@@ -0,0 +1,246 @@
+"""Tests for Lambda Metadata Service utility."""
+
+from __future__ import annotations
+
+from collections import namedtuple
+from unittest.mock import patch
+
+import pytest
+
+from aws_lambda_powertools.utilities.metadata import (
+ LambdaMetadata,
+ LambdaMetadataError,
+ clear_metadata_cache,
+ get_lambda_metadata,
+)
+
+MOCK_METADATA_RESPONSE = {"AvailabilityZoneID": "use1-az1"}
+
+
+@pytest.fixture(autouse=True)
+def _clear_cache():
+ clear_metadata_cache()
+ yield
+ clear_metadata_cache()
+
+
+@pytest.fixture
+def lambda_context():
+ context = {
+ "function_name": "test",
+ "memory_limit_in_mb": 128,
+ "invoked_function_arn": "arn:aws:lambda:eu-west-1:123456789012:function:test",
+ "aws_request_id": "52fdfc07-2182-154f-163f-5f0f9a621d72",
+ }
+ return namedtuple("LambdaContext", context.keys())(*context.values())
+
+
+@pytest.fixture
+def lambda_event():
+ return {"key": "value"}
+
+
+@pytest.fixture
+def mock_metadata_endpoint(monkeypatch):
+ """Simulate a Lambda environment with metadata env vars and mock the HTTP fetch."""
+ monkeypatch.setenv("AWS_LAMBDA_INITIALIZATION_TYPE", "on-demand")
+ monkeypatch.setenv("AWS_LAMBDA_METADATA_API", "127.0.0.1:1234")
+ monkeypatch.setenv("AWS_LAMBDA_METADATA_TOKEN", "test-token")
+
+ with patch(
+ "aws_lambda_powertools.utilities.metadata.lambda_metadata._fetch_metadata",
+ return_value=MOCK_METADATA_RESPONSE,
+ ) as mock_fetch:
+ yield mock_fetch
+
+
+# ---------------------------------------------------------------------------
+# LambdaMetadata dataclass
+# ---------------------------------------------------------------------------
+
+
+def test_lambda_metadata_default_has_none_az():
+ # GIVEN no data
+ # WHEN creating a default LambdaMetadata
+ metadata = LambdaMetadata()
+
+ # THEN availability_zone_id is None
+ assert metadata.availability_zone_id is None
+
+
+def test_lambda_metadata_is_frozen():
+ # GIVEN a LambdaMetadata instance
+ metadata = LambdaMetadata(availability_zone_id="use1-az1")
+
+ # WHEN trying to mutate it
+ # THEN it raises FrozenInstanceError
+ with pytest.raises(AttributeError):
+ metadata.availability_zone_id = "use1-az2"
+
+
+# ---------------------------------------------------------------------------
+# LambdaMetadataError
+# ---------------------------------------------------------------------------
+
+
+def test_lambda_metadata_error_defaults_status_code_to_minus_one():
+ # GIVEN a message only
+ # WHEN creating a LambdaMetadataError
+ err = LambdaMetadataError("something broke")
+
+ # THEN message is set and status_code defaults to -1
+ assert str(err) == "something broke"
+ assert err.status_code == -1
+
+
+def test_lambda_metadata_error_stores_status_code():
+ # GIVEN a message and a status code
+ # WHEN creating a LambdaMetadataError
+ err = LambdaMetadataError("not found", status_code=404)
+
+ # THEN the status_code is stored
+ assert err.status_code == 404
+
+
+# ---------------------------------------------------------------------------
+# get_lambda_metadata – non-Lambda / dev mode
+# ---------------------------------------------------------------------------
+
+
+def test_get_lambda_metadata_returns_empty_outside_lambda(lambda_context, lambda_event, monkeypatch):
+ # GIVEN AWS_LAMBDA_INITIALIZATION_TYPE is not set (local dev / tests)
+ monkeypatch.delenv("AWS_LAMBDA_INITIALIZATION_TYPE", raising=False)
+
+ def handler(event, context):
+ return get_lambda_metadata()
+
+ # WHEN the handler is invoked
+ result = handler(lambda_event, lambda_context)
+
+ # THEN it returns empty metadata without calling the endpoint
+ assert result.availability_zone_id is None
+
+
+def test_get_lambda_metadata_returns_empty_when_dev_mode(lambda_context, lambda_event, monkeypatch):
+ # GIVEN POWERTOOLS_DEV is enabled even though init type is set
+ monkeypatch.setenv("POWERTOOLS_DEV", "true")
+ monkeypatch.setenv("AWS_LAMBDA_INITIALIZATION_TYPE", "on-demand")
+
+ def handler(event, context):
+ return get_lambda_metadata()
+
+ # WHEN the handler is invoked
+ result = handler(lambda_event, lambda_context)
+
+ # THEN it returns empty metadata
+ assert result.availability_zone_id is None
+
+
+# ---------------------------------------------------------------------------
+# get_lambda_metadata – missing env vars
+# ---------------------------------------------------------------------------
+
+
+def test_get_lambda_metadata_raises_when_api_env_var_missing(lambda_context, lambda_event, monkeypatch):
+ # GIVEN a Lambda environment without AWS_LAMBDA_METADATA_API
+ monkeypatch.setenv("AWS_LAMBDA_INITIALIZATION_TYPE", "on-demand")
+ monkeypatch.setenv("AWS_LAMBDA_METADATA_TOKEN", "tok")
+ monkeypatch.delenv("AWS_LAMBDA_METADATA_API", raising=False)
+
+ def handler(event, context):
+ return get_lambda_metadata()
+
+ # WHEN the handler is invoked
+ # THEN it raises LambdaMetadataError mentioning the missing var
+ with pytest.raises(LambdaMetadataError, match="AWS_LAMBDA_METADATA_API"):
+ handler(lambda_event, lambda_context)
+
+
+def test_get_lambda_metadata_raises_when_token_env_var_missing(lambda_context, lambda_event, monkeypatch):
+ # GIVEN a Lambda environment without AWS_LAMBDA_METADATA_TOKEN
+ monkeypatch.setenv("AWS_LAMBDA_INITIALIZATION_TYPE", "on-demand")
+ monkeypatch.setenv("AWS_LAMBDA_METADATA_API", "127.0.0.1:9999")
+ monkeypatch.delenv("AWS_LAMBDA_METADATA_TOKEN", raising=False)
+
+ def handler(event, context):
+ return get_lambda_metadata()
+
+ # WHEN the handler is invoked
+ # THEN it raises LambdaMetadataError mentioning the missing var
+ with pytest.raises(LambdaMetadataError, match="AWS_LAMBDA_METADATA_TOKEN"):
+ handler(lambda_event, lambda_context)
+
+
+# ---------------------------------------------------------------------------
+# get_lambda_metadata – happy path
+# ---------------------------------------------------------------------------
+
+
+def test_get_lambda_metadata_returns_az_id(lambda_context, lambda_event, mock_metadata_endpoint):
+ # GIVEN a Lambda environment with metadata env vars configured
+ def handler(event, context):
+ return get_lambda_metadata()
+
+ # WHEN the handler is invoked
+ result = handler(lambda_event, lambda_context)
+
+ # THEN it returns metadata with the availability zone id
+ assert result.availability_zone_id == "use1-az1"
+ mock_metadata_endpoint.assert_called_once()
+
+
+def test_get_lambda_metadata_caches_across_invocations(lambda_context, lambda_event, mock_metadata_endpoint):
+ # GIVEN a Lambda environment with metadata env vars configured
+ def handler(event, context):
+ return get_lambda_metadata()
+
+ # WHEN the handler is invoked twice (simulating warm start)
+ first = handler(lambda_event, lambda_context)
+ second = handler(lambda_event, lambda_context)
+
+ # THEN both return the same data and the endpoint was called only once
+ assert first.availability_zone_id == "use1-az1"
+ assert second.availability_zone_id == "use1-az1"
+ mock_metadata_endpoint.assert_called_once()
+
+
+def test_get_lambda_metadata_refetches_after_cache_clear(lambda_context, lambda_event, mock_metadata_endpoint):
+ # GIVEN a Lambda environment with metadata env vars configured
+ def handler(event, context):
+ return get_lambda_metadata()
+
+ # WHEN the handler is invoked, cache is cleared, then invoked again
+ first = handler(lambda_event, lambda_context)
+ clear_metadata_cache()
+ second = handler(lambda_event, lambda_context)
+
+ # THEN the endpoint was called twice (cache was invalidated)
+ assert first.availability_zone_id == "use1-az1"
+ assert second.availability_zone_id == "use1-az1"
+ assert mock_metadata_endpoint.call_count == 2
+
+
+# ---------------------------------------------------------------------------
+# get_lambda_metadata – error responses
+# ---------------------------------------------------------------------------
+
+
+def test_get_lambda_metadata_raises_on_endpoint_error(lambda_context, lambda_event, monkeypatch):
+ # GIVEN a Lambda environment where the endpoint returns a 500
+ monkeypatch.setenv("AWS_LAMBDA_INITIALIZATION_TYPE", "on-demand")
+ monkeypatch.setenv("AWS_LAMBDA_METADATA_API", "127.0.0.1:1234")
+ monkeypatch.setenv("AWS_LAMBDA_METADATA_TOKEN", "test-token")
+
+ def handler(event, context):
+ return get_lambda_metadata()
+
+ # WHEN the handler is invoked and the endpoint fails
+ with patch(
+ "aws_lambda_powertools.utilities.metadata.lambda_metadata._fetch_metadata",
+ side_effect=LambdaMetadataError("Metadata request failed with status 500", status_code=500),
+ ):
+ # THEN it raises LambdaMetadataError with the status code
+ with pytest.raises(LambdaMetadataError, match="status 500") as exc_info:
+ handler(lambda_event, lambda_context)
+
+ assert exc_info.value.status_code == 500
diff --git a/tests/functional/validator/_fastjsonschema/test_validator.py b/tests/functional/validator/_fastjsonschema/test_validator.py
index d29efd09cae..e14bc5e01c1 100644
--- a/tests/functional/validator/_fastjsonschema/test_validator.py
+++ b/tests/functional/validator/_fastjsonschema/test_validator.py
@@ -77,12 +77,13 @@ def test_validate_accept_schema_custom_format(
)
-@pytest.mark.parametrize("invalid_format", [None, False, {}, [], object])
+@pytest.mark.parametrize("invalid_format", [object, 123, 1.5, True])
def test_validate_invalid_custom_format(
eventbridge_schema_registry_cloudtrail_v2_s3,
eventbridge_cloudtrail_s3_head_object_event,
invalid_format,
):
+ # formats must be a mapping; anything we can't look a format name up in fails to compile
with pytest.raises(exceptions.InvalidSchemaFormatError):
validate(
event=eventbridge_cloudtrail_s3_head_object_event,
diff --git a/tests/integration/metadata/__init__.py b/tests/integration/metadata/__init__.py
new file mode 100644
index 00000000000..8b137891791
--- /dev/null
+++ b/tests/integration/metadata/__init__.py
@@ -0,0 +1 @@
+
diff --git a/tests/integration/metadata/test_lambda_metadata_http.py b/tests/integration/metadata/test_lambda_metadata_http.py
new file mode 100644
index 00000000000..6354179f34a
--- /dev/null
+++ b/tests/integration/metadata/test_lambda_metadata_http.py
@@ -0,0 +1,192 @@
+"""Integration tests for Lambda Metadata Service – exercises the real HTTP path."""
+
+from __future__ import annotations
+
+import http.server
+import json
+from collections import namedtuple
+
+import pytest
+
+from aws_lambda_powertools.utilities.metadata import (
+ LambdaMetadataError,
+ clear_metadata_cache,
+ get_lambda_metadata,
+)
+
+
+@pytest.fixture(autouse=True)
+def _clear_cache():
+ clear_metadata_cache()
+ yield
+ clear_metadata_cache()
+
+
+@pytest.fixture
+def lambda_context():
+ context = {
+ "function_name": "test",
+ "memory_limit_in_mb": 128,
+ "invoked_function_arn": "arn:aws:lambda:eu-west-1:123456789012:function:test",
+ "aws_request_id": "52fdfc07-2182-154f-163f-5f0f9a621d72",
+ }
+ return namedtuple("LambdaContext", context.keys())(*context.values())
+
+
+@pytest.fixture
+def lambda_event():
+ return {"key": "value"}
+
+
+# ---------------------------------------------------------------------------
+# HTTP server fixtures
+# ---------------------------------------------------------------------------
+
+
+def _make_handler(status: int, body: str):
+ """Create an HTTP handler that returns a fixed status and body."""
+
+ class Handler(http.server.BaseHTTPRequestHandler):
+ def do_GET(self): # noqa: N802
+ self.send_response(status)
+ self.send_header("Content-Type", "application/json")
+ self.end_headers()
+ self.wfile.write(body.encode())
+
+ def log_message(self, format, *args): # noqa: A002
+ pass
+
+ return Handler
+
+
+@pytest.fixture
+def metadata_server(monkeypatch):
+ """Start a local HTTP server returning valid metadata and set env vars."""
+ body = json.dumps({"AvailabilityZoneID": "use1-az1"})
+ server = http.server.HTTPServer(("127.0.0.1", 0), _make_handler(200, body))
+ port = server.server_address[1]
+
+ import threading
+
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
+ thread.start()
+
+ monkeypatch.setenv("AWS_LAMBDA_INITIALIZATION_TYPE", "on-demand")
+ monkeypatch.setenv("AWS_LAMBDA_METADATA_API", f"127.0.0.1:{port}")
+ monkeypatch.setenv("AWS_LAMBDA_METADATA_TOKEN", "test-token")
+
+ yield server
+ server.shutdown()
+
+
+@pytest.fixture
+def error_server(monkeypatch):
+ """Start a local HTTP server returning 500 and set env vars."""
+ server = http.server.HTTPServer(("127.0.0.1", 0), _make_handler(500, "Internal Server Error"))
+ port = server.server_address[1]
+
+ import threading
+
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
+ thread.start()
+
+ monkeypatch.setenv("AWS_LAMBDA_INITIALIZATION_TYPE", "on-demand")
+ monkeypatch.setenv("AWS_LAMBDA_METADATA_API", f"127.0.0.1:{port}")
+ monkeypatch.setenv("AWS_LAMBDA_METADATA_TOKEN", "test-token")
+
+ yield server
+ server.shutdown()
+
+
+@pytest.fixture
+def invalid_json_server(monkeypatch):
+ """Start a local HTTP server returning invalid JSON."""
+ server = http.server.HTTPServer(("127.0.0.1", 0), _make_handler(200, "not-json"))
+ port = server.server_address[1]
+
+ import threading
+
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
+ thread.start()
+
+ monkeypatch.setenv("AWS_LAMBDA_INITIALIZATION_TYPE", "on-demand")
+ monkeypatch.setenv("AWS_LAMBDA_METADATA_API", f"127.0.0.1:{port}")
+ monkeypatch.setenv("AWS_LAMBDA_METADATA_TOKEN", "test-token")
+
+ yield server
+ server.shutdown()
+
+
+# ---------------------------------------------------------------------------
+# Tests – happy path
+# ---------------------------------------------------------------------------
+
+
+def test_fetch_metadata_returns_az_id(lambda_context, lambda_event, metadata_server):
+ # GIVEN a Lambda environment pointing to a local metadata endpoint
+ def lambda_handler(event, context):
+ return get_lambda_metadata()
+
+ # WHEN the handler is invoked
+ result = lambda_handler(lambda_event, lambda_context)
+
+ # THEN it returns metadata with the availability zone id
+ assert result.availability_zone_id == "use1-az1"
+
+
+def test_fetch_metadata_caches_across_invocations(lambda_context, lambda_event, metadata_server):
+ # GIVEN a Lambda environment pointing to a local metadata endpoint
+ def lambda_handler(event, context):
+ return get_lambda_metadata()
+
+ # WHEN the handler is invoked twice (warm start)
+ first = lambda_handler(lambda_event, lambda_context)
+ second = lambda_handler(lambda_event, lambda_context)
+
+ # THEN both return the same data
+ assert first.availability_zone_id == "use1-az1"
+ assert second.availability_zone_id == "use1-az1"
+
+
+# ---------------------------------------------------------------------------
+# Tests – error paths
+# ---------------------------------------------------------------------------
+
+
+def test_fetch_metadata_raises_on_http_500(lambda_context, lambda_event, error_server):
+ # GIVEN a Lambda environment where the endpoint returns 500
+ def lambda_handler(event, context):
+ return get_lambda_metadata()
+
+ # WHEN the handler is invoked
+ # THEN it raises LambdaMetadataError with status code 500
+ with pytest.raises(LambdaMetadataError, match="status 500") as exc_info:
+ lambda_handler(lambda_event, lambda_context)
+
+ assert exc_info.value.status_code == 500
+
+
+def test_fetch_metadata_raises_on_invalid_json(lambda_context, lambda_event, invalid_json_server):
+ # GIVEN a Lambda environment where the endpoint returns invalid JSON
+ def lambda_handler(event, context):
+ return get_lambda_metadata()
+
+ # WHEN the handler is invoked
+ # THEN it raises LambdaMetadataError about parsing
+ with pytest.raises(LambdaMetadataError, match="Failed to parse"):
+ lambda_handler(lambda_event, lambda_context)
+
+
+def test_fetch_metadata_raises_on_unreachable_endpoint(lambda_context, lambda_event, monkeypatch):
+ # GIVEN a Lambda environment pointing to an unreachable endpoint
+ monkeypatch.setenv("AWS_LAMBDA_INITIALIZATION_TYPE", "on-demand")
+ monkeypatch.setenv("AWS_LAMBDA_METADATA_API", "127.0.0.1:1")
+ monkeypatch.setenv("AWS_LAMBDA_METADATA_TOKEN", "test-token")
+
+ def lambda_handler(event, context):
+ return get_lambda_metadata(timeout=0.1)
+
+ # WHEN the handler is invoked
+ # THEN it raises LambdaMetadataError about connection failure
+ with pytest.raises(LambdaMetadataError, match="Failed to fetch"):
+ lambda_handler(lambda_event, lambda_context)
diff --git a/tests/performance/data_masking/load_test_data_masking/pt-load-test-stack/function_1024/requirements.txt b/tests/performance/data_masking/load_test_data_masking/pt-load-test-stack/function_1024/requirements.txt
index 1c37b95e202..c168cb36ead 100644
--- a/tests/performance/data_masking/load_test_data_masking/pt-load-test-stack/function_1024/requirements.txt
+++ b/tests/performance/data_masking/load_test_data_masking/pt-load-test-stack/function_1024/requirements.txt
@@ -1,3 +1,3 @@
requests>=2.32.0
aws-lambda-powertools[tracer]
-aws-encryption-sdk>=3.1.1
+aws-encryption-sdk>=4.0.5,<5.0.0
diff --git a/tests/performance/data_masking/load_test_data_masking/pt-load-test-stack/function_128/requirements.txt b/tests/performance/data_masking/load_test_data_masking/pt-load-test-stack/function_128/requirements.txt
index 1c37b95e202..c168cb36ead 100644
--- a/tests/performance/data_masking/load_test_data_masking/pt-load-test-stack/function_128/requirements.txt
+++ b/tests/performance/data_masking/load_test_data_masking/pt-load-test-stack/function_128/requirements.txt
@@ -1,3 +1,3 @@
requests>=2.32.0
aws-lambda-powertools[tracer]
-aws-encryption-sdk>=3.1.1
+aws-encryption-sdk>=4.0.5,<5.0.0
diff --git a/tests/performance/data_masking/load_test_data_masking/pt-load-test-stack/function_1769/requirements.txt b/tests/performance/data_masking/load_test_data_masking/pt-load-test-stack/function_1769/requirements.txt
index 1c37b95e202..c168cb36ead 100644
--- a/tests/performance/data_masking/load_test_data_masking/pt-load-test-stack/function_1769/requirements.txt
+++ b/tests/performance/data_masking/load_test_data_masking/pt-load-test-stack/function_1769/requirements.txt
@@ -1,3 +1,3 @@
requests>=2.32.0
aws-lambda-powertools[tracer]
-aws-encryption-sdk>=3.1.1
+aws-encryption-sdk>=4.0.5,<5.0.0
diff --git a/tests/unit/circuit_breaker/__init__.py b/tests/unit/circuit_breaker/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/tests/unit/circuit_breaker/test_config_and_states.py b/tests/unit/circuit_breaker/test_config_and_states.py
new file mode 100644
index 00000000000..03700dc91d3
--- /dev/null
+++ b/tests/unit/circuit_breaker/test_config_and_states.py
@@ -0,0 +1,138 @@
+from __future__ import annotations
+
+import dataclasses
+
+import pytest
+
+from aws_lambda_powertools.utilities.circuit_breaker.config import CircuitBreakerConfig
+from aws_lambda_powertools.utilities.circuit_breaker.exceptions import (
+ CircuitBreakerConfigError,
+ CircuitBreakerOpenError,
+)
+from aws_lambda_powertools.utilities.circuit_breaker.persistence.record import CircuitStateRecord
+from aws_lambda_powertools.utilities.circuit_breaker.states import CircuitInfo, CircuitState
+
+
+def test_circuit_state_serializes_to_plain_string():
+ assert str(CircuitState.OPEN) == "OPEN"
+ assert CircuitState.OPEN == "OPEN"
+
+
+def test_circuit_info_is_immutable():
+ info = CircuitInfo(name="payment", state=CircuitState.OPEN, failure_count=5, opened_at=123)
+ with pytest.raises(dataclasses.FrozenInstanceError):
+ info.name = "other" # type: ignore[misc]
+
+
+def test_config_defaults():
+ config = CircuitBreakerConfig()
+ assert config.failure_threshold == 5
+ assert config.recovery_timeout == 30
+ assert config.success_threshold == 3
+ assert config.local_cache_max_age == 5
+ assert config.handled_exceptions is None
+ assert config.ignored_exceptions is None
+
+
+def test_config_rejects_both_exception_lists():
+ with pytest.raises(CircuitBreakerConfigError, match="mutually exclusive"):
+ CircuitBreakerConfig(handled_exceptions=(TimeoutError,), ignored_exceptions=(ValueError,))
+
+
+@pytest.mark.parametrize("field", ["failure_threshold", "recovery_timeout", "success_threshold"])
+def test_config_rejects_non_positive_thresholds(field):
+ with pytest.raises(CircuitBreakerConfigError, match="positive integer"):
+ CircuitBreakerConfig(**{field: 0})
+
+
+def test_config_allows_zero_cache_age():
+ assert CircuitBreakerConfig(local_cache_max_age=0).local_cache_max_age == 0
+
+
+def test_config_rejects_negative_cache_age():
+ with pytest.raises(CircuitBreakerConfigError, match="non-negative"):
+ CircuitBreakerConfig(local_cache_max_age=-1)
+
+
+def test_counts_as_failure_default_any_exception():
+ config = CircuitBreakerConfig()
+ assert config.counts_as_failure(ValueError()) is True
+ assert config.counts_as_failure(TimeoutError()) is True
+
+
+def test_counts_as_failure_allowlist():
+ config = CircuitBreakerConfig(handled_exceptions=(TimeoutError, ConnectionError))
+ assert config.counts_as_failure(TimeoutError()) is True
+ assert config.counts_as_failure(ValueError()) is False
+
+
+def test_counts_as_failure_denylist():
+ config = CircuitBreakerConfig(ignored_exceptions=(ValueError,))
+ assert config.counts_as_failure(ValueError()) is False
+ assert config.counts_as_failure(KeyError()) is True
+
+
+def test_config_normalizes_handled_exceptions_list_to_tuple():
+ config = CircuitBreakerConfig(handled_exceptions=[TimeoutError, ConnectionError])
+ assert config.handled_exceptions == (TimeoutError, ConnectionError)
+ # The reported bug: a list must not break counts_as_failure when the circuit evaluates a failure.
+ assert config.counts_as_failure(TimeoutError()) is True
+ assert config.counts_as_failure(ValueError()) is False
+
+
+def test_config_normalizes_single_exception_type_to_tuple():
+ config = CircuitBreakerConfig(handled_exceptions=ValueError)
+ assert config.handled_exceptions == (ValueError,)
+ assert config.counts_as_failure(ValueError()) is True
+
+
+def test_config_normalizes_ignored_exceptions_list_to_tuple():
+ config = CircuitBreakerConfig(ignored_exceptions=[ValueError])
+ assert config.ignored_exceptions == (ValueError,)
+ assert config.counts_as_failure(ValueError()) is False
+ assert config.counts_as_failure(KeyError()) is True
+
+
+def test_config_normalizes_iterator_of_exceptions():
+ config = CircuitBreakerConfig(handled_exceptions=iter((TimeoutError, KeyError)))
+ assert config.handled_exceptions == (TimeoutError, KeyError)
+
+
+@pytest.mark.parametrize("field", ["handled_exceptions", "ignored_exceptions"])
+def test_config_rejects_non_exception_type_in_list(field):
+ with pytest.raises(CircuitBreakerConfigError, match="only exception types"):
+ CircuitBreakerConfig(**{field: ["not-an-exception"]})
+
+
+@pytest.mark.parametrize("field", ["handled_exceptions", "ignored_exceptions"])
+@pytest.mark.parametrize("value", [5, "ValueError"])
+def test_config_rejects_non_iterable_or_str_exceptions(field, value):
+ with pytest.raises(CircuitBreakerConfigError, match="iterable of exception types"):
+ CircuitBreakerConfig(**{field: value})
+
+
+@pytest.mark.parametrize("field", ["handled_exceptions", "ignored_exceptions"])
+def test_config_rejects_empty_exceptions(field):
+ with pytest.raises(CircuitBreakerConfigError, match="at least one exception type"):
+ CircuitBreakerConfig(**{field: []})
+
+
+def test_open_error_carries_circuit_info():
+ info = CircuitInfo(name="payment", state=CircuitState.OPEN, failure_count=5, opened_at=123)
+ error = CircuitBreakerOpenError("open", circuit=info)
+ assert error.circuit is info
+
+
+def test_record_to_circuit_info_strips_internal_fields():
+ record = CircuitStateRecord(
+ name="payment",
+ state=CircuitState.OPEN,
+ failure_count=5,
+ opened_at=123,
+ half_open_owner="env-abc",
+ expiry_timestamp=999,
+ )
+ info = record.to_circuit_info()
+ assert info == CircuitInfo(name="payment", state=CircuitState.OPEN, failure_count=5, opened_at=123)
+ assert not hasattr(info, "half_open_owner")
+ assert not hasattr(info, "expiry_timestamp")
diff --git a/tests/unit/data_classes/required_dependencies/test_alb_event.py b/tests/unit/data_classes/required_dependencies/test_alb_event.py
index 13d8b5907be..23ab7af6365 100644
--- a/tests/unit/data_classes/required_dependencies/test_alb_event.py
+++ b/tests/unit/data_classes/required_dependencies/test_alb_event.py
@@ -52,3 +52,17 @@ def test_alb_event_decode_multi_value_query_parameters():
# With decode_query_parameters, the key and value are not decoded
parsed_event.decode_query_parameters = True
assert parsed_event.resolved_query_string_parameters == {expected_key: expected_values}
+
+
+def test_alb_event_merged_query_string_parameters():
+ """When both multiValueQueryStringParameters and queryStringParameters are present,
+ resolved_query_string_parameters should merge them (GH #7993)."""
+ raw_event = load_event("albMultiValueQueryStringEvent.json")
+ raw_event["multiValueQueryStringParameters"] = {"ids": ["1", "2", "3"]}
+ raw_event["queryStringParameters"] = {"status": "fizzbuzz"}
+
+ parsed_event = ALBEvent(raw_event)
+ resolved = parsed_event.resolved_query_string_parameters
+
+ assert resolved["ids"] == ["1", "2", "3"]
+ assert resolved["status"] == ["fizzbuzz"]
diff --git a/tests/unit/data_classes/required_dependencies/test_api_gateway_authorizer.py b/tests/unit/data_classes/required_dependencies/test_api_gateway_authorizer.py
index 1fad5176672..c26c1a417e7 100644
--- a/tests/unit/data_classes/required_dependencies/test_api_gateway_authorizer.py
+++ b/tests/unit/data_classes/required_dependencies/test_api_gateway_authorizer.py
@@ -200,6 +200,40 @@ def test_authorizer_response_allow_route_with_underscore(builder: APIGatewayAuth
}
+def test_authorizer_response_allow_route_with_proxy_plus(builder: APIGatewayAuthorizerResponse):
+ builder.allow_route(http_method="GET", resource="/{proxy+}")
+ assert builder.asdict() == {
+ "principalId": "foo",
+ "policyDocument": {
+ "Version": "2012-10-17",
+ "Statement": [
+ {
+ "Action": "execute-api:Invoke",
+ "Effect": "Allow",
+ "Resource": ["arn:aws:execute-api:us-west-1:123456789:fantom/dev/GET/{proxy+}"],
+ },
+ ],
+ },
+ }
+
+
+def test_authorizer_response_allow_route_with_path_parameter(builder: APIGatewayAuthorizerResponse):
+ builder.allow_route(http_method="GET", resource="/users/{user_id}")
+ assert builder.asdict() == {
+ "principalId": "foo",
+ "policyDocument": {
+ "Version": "2012-10-17",
+ "Statement": [
+ {
+ "Action": "execute-api:Invoke",
+ "Effect": "Allow",
+ "Resource": ["arn:aws:execute-api:us-west-1:123456789:fantom/dev/GET/users/{user_id}"],
+ },
+ ],
+ },
+ }
+
+
def test_parse_api_gateway_arn_with_resource():
mock_event = {
"type": "TOKEN",
diff --git a/tests/unit/data_classes/required_dependencies/test_api_gateway_proxy_event.py b/tests/unit/data_classes/required_dependencies/test_api_gateway_proxy_event.py
index ec71d815a7c..fd9ca1cca76 100644
--- a/tests/unit/data_classes/required_dependencies/test_api_gateway_proxy_event.py
+++ b/tests/unit/data_classes/required_dependencies/test_api_gateway_proxy_event.py
@@ -241,3 +241,53 @@ def test_api_gateway_proxy_v2_iam_event():
assert iam.principal_org_id == iam_raw["principalOrgId"]
assert iam.user_arn == iam_raw["userArn"]
assert iam.user_id == iam_raw["userId"]
+
+
+def test_api_gateway_proxy_event_merged_query_string_parameters():
+ """When both multiValueQueryStringParameters and queryStringParameters are present,
+ resolved_query_string_parameters should merge them (GH #7993)."""
+ raw_event = load_event("apiGatewayProxyEvent.json")
+ raw_event["multiValueQueryStringParameters"] = {"ids": ["1", "2", "3"]}
+ raw_event["queryStringParameters"] = {"status": "fizzbuzz"}
+
+ parsed_event = APIGatewayProxyEvent(raw_event)
+ resolved = parsed_event.resolved_query_string_parameters
+
+ assert resolved["ids"] == ["1", "2", "3"]
+ assert resolved["status"] == ["fizzbuzz"]
+
+
+def test_api_gateway_proxy_event_multi_value_takes_precedence():
+ """When the same key exists in both, multiValueQueryStringParameters wins."""
+ raw_event = load_event("apiGatewayProxyEvent.json")
+ raw_event["multiValueQueryStringParameters"] = {"key": ["a", "b"]}
+ raw_event["queryStringParameters"] = {"key": "c"}
+
+ parsed_event = APIGatewayProxyEvent(raw_event)
+ resolved = parsed_event.resolved_query_string_parameters
+
+ assert resolved["key"] == ["a", "b"]
+
+
+def test_api_gateway_proxy_event_only_single_value_query_params():
+ """When only queryStringParameters is present, it should still work."""
+ raw_event = load_event("apiGatewayProxyEvent.json")
+ raw_event["multiValueQueryStringParameters"] = None
+ raw_event["queryStringParameters"] = {"status": "active"}
+
+ parsed_event = APIGatewayProxyEvent(raw_event)
+ resolved = parsed_event.resolved_query_string_parameters
+
+ assert resolved["status"] == ["active"]
+
+
+def test_api_gateway_proxy_event_only_multi_value_query_params():
+ """When only multiValueQueryStringParameters is present, it should still work."""
+ raw_event = load_event("apiGatewayProxyEvent.json")
+ raw_event["multiValueQueryStringParameters"] = {"ids": ["1", "2"]}
+ raw_event["queryStringParameters"] = None
+
+ parsed_event = APIGatewayProxyEvent(raw_event)
+ resolved = parsed_event.resolved_query_string_parameters
+
+ assert resolved["ids"] == ["1", "2"]
diff --git a/tests/unit/data_classes/required_dependencies/test_bedrock_agent_function_event.py b/tests/unit/data_classes/required_dependencies/test_bedrock_agent_function_event.py
index e055c894604..2ff81a21bb8 100644
--- a/tests/unit/data_classes/required_dependencies/test_bedrock_agent_function_event.py
+++ b/tests/unit/data_classes/required_dependencies/test_bedrock_agent_function_event.py
@@ -32,7 +32,7 @@ def test_bedrock_agent_function_event():
raw_parameters = raw_event["parameters"]
assert len(parameters) == len(raw_parameters)
- for param, raw_param in zip(parameters, raw_parameters):
+ for param, raw_param in zip(parameters, raw_parameters, strict=True):
assert param.name == raw_param["name"]
assert param.type == raw_param["type"]
assert param.value == raw_param["value"]
diff --git a/tests/unit/data_classes/required_dependencies/test_event_bridge_event.py b/tests/unit/data_classes/required_dependencies/test_event_bridge_event.py
index 6dfc0c82485..e581cacae32 100644
--- a/tests/unit/data_classes/required_dependencies/test_event_bridge_event.py
+++ b/tests/unit/data_classes/required_dependencies/test_event_bridge_event.py
@@ -17,4 +17,4 @@ def test_event_bridge_event():
assert parsed_event.source == raw_event["source"]
assert parsed_event.detail_type == raw_event["detail-type"]
assert parsed_event.detail == raw_event["detail"]
- assert parsed_event.replay_name == "replay_archive"
+ assert parsed_event.replay_name is None
diff --git a/tests/unit/event_handler/openapi/test_openapi_merge.py b/tests/unit/event_handler/openapi/test_openapi_merge.py
index 21500145b35..bce18b62dea 100644
--- a/tests/unit/event_handler/openapi/test_openapi_merge.py
+++ b/tests/unit/event_handler/openapi/test_openapi_merge.py
@@ -10,7 +10,7 @@
_discover_resolver_files,
_file_has_resolver,
_is_excluded,
- _load_resolver,
+ _load_resolver_with_dependencies,
)
MERGE_HANDLERS_PATH = Path(__file__).parents[3] / "functional/event_handler/_pydantic/merge_handlers"
@@ -71,7 +71,7 @@ def test_is_excluded_with_file_pattern():
def test_load_resolver_file_not_found():
with pytest.raises(FileNotFoundError):
- _load_resolver(Path("/non/existent/file.py"), "app")
+ _load_resolver_with_dependencies(Path("/non/existent/file.py"), "app", [], Path("/"))
def test_load_resolver_not_found_in_module(tmp_path: Path):
@@ -79,7 +79,7 @@ def test_load_resolver_not_found_in_module(tmp_path: Path):
handler_file.write_text("x = 1")
with pytest.raises(AttributeError, match="Resolver 'app' not found"):
- _load_resolver(handler_file, "app")
+ _load_resolver_with_dependencies(handler_file, "app", [], tmp_path)
def test_load_resolver_success(tmp_path: Path):
@@ -93,6 +93,6 @@ def test_endpoint():
return {"test": True}
""")
- resolver = _load_resolver(handler_file, "app")
+ resolver = _load_resolver_with_dependencies(handler_file, "app", [], tmp_path)
assert resolver is not None
assert hasattr(resolver, "get_openapi_schema")
diff --git a/tests/unit/parser/_pydantic/schemas.py b/tests/unit/parser/_pydantic/schemas.py
index 0713924c486..2565d649ee9 100644
--- a/tests/unit/parser/_pydantic/schemas.py
+++ b/tests/unit/parser/_pydantic/schemas.py
@@ -1,4 +1,4 @@
-from typing import List, Optional
+from typing import List
from pydantic import BaseModel
@@ -21,8 +21,8 @@ class MyDynamoBusiness(BaseModel):
class MyDynamoScheme(DynamoDBStreamChangedRecordModel):
- NewImage: Optional[MyDynamoBusiness] = None
- OldImage: Optional[MyDynamoBusiness] = None
+ NewImage: MyDynamoBusiness | None = None
+ OldImage: MyDynamoBusiness | None = None
class MyDynamoDBStreamRecordModel(DynamoDBStreamRecordModel):
diff --git a/tests/unit/parser/_pydantic/test_dynamodb.py b/tests/unit/parser/_pydantic/test_dynamodb.py
index 13ee5610e6d..f21f6d717c3 100644
--- a/tests/unit/parser/_pydantic/test_dynamodb.py
+++ b/tests/unit/parser/_pydantic/test_dynamodb.py
@@ -1,3 +1,5 @@
+from typing import Any
+
import pytest
from aws_lambda_powertools.utilities.parser import ValidationError, envelopes, parse
@@ -86,8 +88,26 @@ def test_validate_event_does_not_conform_with_model():
parse(event=raw_event, model=MyDynamoBusiness, envelope=envelopes.DynamoDBStreamEnvelope)
-def test_dynamo_db_stream_lambda_invocation_event():
+@pytest.mark.parametrize(
+ "response_context",
+ [
+ pytest.param(
+ {"statusCode": 200, "executedVersion": "$LATEST"},
+ id="without function error",
+ ),
+ pytest.param(
+ {"statusCode": 200, "executedVersion": "$LATEST", "functionError": None},
+ id="with null function error",
+ ),
+ pytest.param(
+ {"statusCode": 200, "executedVersion": "$LATEST", "functionError": "Unhandled"},
+ id="with function error",
+ ),
+ ],
+)
+def test_dynamo_db_stream_lambda_invocation_event(response_context: dict[str, Any]):
raw_event = load_event("dynamoStreamLambdaInvocationEvent.json")
+ raw_event["responseContext"] = response_context
parsed_event: DynamoDBStreamLambdaOnFailureDestinationModel = parse(
event=raw_event,
model=DynamoDBStreamLambdaOnFailureDestinationModel,
@@ -111,6 +131,8 @@ def test_dynamo_db_stream_lambda_invocation_event():
)
assert parsed_event.ddb_stream_batch_info.stream_arn == raw_event["DDBStreamBatchInfo"]["streamArn"]
assert parsed_event.request_context.model_dump(by_alias=True) == raw_event["requestContext"]
- assert parsed_event.response_context.model_dump(by_alias=True) == raw_event["responseContext"]
+ assert parsed_event.response_context.status_code == response_context["statusCode"]
+ assert parsed_event.response_context.executed_version == response_context["executedVersion"]
+ assert parsed_event.response_context.function_error == response_context.get("functionError")
assert parsed_event.timestamp.strftime("%Y-%m-%dT%H:%M:%SZ") == raw_event["timestamp"]
assert parsed_event.version == raw_event["version"]
diff --git a/tests/unit/parser/_pydantic/test_eventbridge.py b/tests/unit/parser/_pydantic/test_eventbridge.py
index 585406e7095..86ebc5bc204 100644
--- a/tests/unit/parser/_pydantic/test_eventbridge.py
+++ b/tests/unit/parser/_pydantic/test_eventbridge.py
@@ -45,7 +45,7 @@ def test_handle_eventbridge_trigger_event_no_envelope():
assert parsed_event.resources == raw_event["resources"]
assert parsed_event.source == raw_event["source"]
assert parsed_event.detail_type == raw_event["detail-type"]
- assert parsed_event.replay_name == raw_event["replay-name"]
+ assert parsed_event.replay_name is None
def test_handle_invalid_event_with_eventbridge_envelope():