Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions aws_lambda_powertools/event_handler/api_gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -3336,6 +3336,12 @@ def register_route(func: AnyCallableT) -> AnyCallableT:
status_code,
)

if route_key in self._routes:
warnings.warn(
f"A route like this was already registered. method: '{', '.join(methods)}' rule: '{rule}'",
stacklevel=2,
)

# Collate Middleware for routes
if middlewares is not None:
for handler in middlewares:
Expand Down
22 changes: 12 additions & 10 deletions aws_lambda_powertools/event_handler/appsync.py
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@ def lambda_handler(event, context):
self.lambda_context = context
Router.lambda_context = context

response = None
try:
if isinstance(event, list):
Router.current_batch_event = [data_model(e) for e in event]
Expand All @@ -157,16 +158,17 @@ def lambda_handler(event, context):
except Exception as exp:
response_builder = self.exception_handler_manager.lookup_exception_handler(type(exp))
if response_builder:
return response_builder(exp)
raise

# We don't clear the context for coroutines because we don't have control over the event loop.
# If we clean the context immediately, it might not be available when the coroutine is actually executed.
# For single async operations, the context should be cleaned up manually after the coroutine completes.
# See: https://github.com/aws-powertools/powertools-lambda-python/issues/5290
# REVIEW: Review this support in Powertools V4
if not asyncio.iscoroutine(response):
self.clear_context()
response = response_builder(exp)
else:
raise
finally:
# We don't clear the context for coroutines because we don't have control over the event loop.
# If we clean the context immediately, it might not be available when the coroutine is actually executed.
# For single async operations, the context should be cleaned up manually after the coroutine completes.
# See: https://github.com/aws-powertools/powertools-lambda-python/issues/5290
# REVIEW: Review this support in Powertools V4
if not asyncio.iscoroutine(response):
self.clear_context()

return response

Expand Down
11 changes: 8 additions & 3 deletions aws_lambda_powertools/event_handler/bedrock_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ def build(self, event: BedrockAgentEvent, *args) -> dict[str, Any]:
"httpMethod": event.http_method,
"httpStatusCode": self.response.status_code,
"responseBody": {
self.response.content_type: {
self.response.content_type or "application/json": {
"body": body,
},
},
Expand Down Expand Up @@ -346,8 +346,13 @@ def _convert_matches_into_route_keys(self, match: Match) -> dict[str, str]:
# In Bedrock Agents, all the parameters come inside the "parameters" key, not on the apiPath
# So we have to search for route parameters in the parameters key
parameters: dict[str, str] = {}
if match.groupdict() and self.current_event.parameters:
parameters = {parameter["name"]: parameter["value"] for parameter in self.current_event.parameters}
route_keys = match.groupdict()
if route_keys and self.current_event.parameters:
parameters = {
parameter["name"]: parameter["value"]
for parameter in self.current_event.parameters
if parameter["name"] in route_keys
}
return parameters

@override
Expand Down
9 changes: 7 additions & 2 deletions aws_lambda_powertools/event_handler/depends.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,11 @@ def _get_depends_from_annotation(annotation: Any) -> Depends | None:
return None


def _annotation_target(func: Callable[..., Any]) -> Callable[..., Any]:
"""A class carries its dependency annotations on __init__, not on the class itself."""
return func.__init__ if isinstance(func, type) else func # type: ignore[misc]


def _has_depends(func: Callable[..., Any]) -> bool:
"""Check if a callable has any Depends() parameters, without importing pydantic."""
try:
Expand All @@ -134,7 +139,7 @@ def build_dependency_tree(func: Callable[..., Any]) -> DependencyTree:
annotations and recursively builds the tree — all without importing pydantic.
"""
try:
hints = get_type_hints(func, include_extras=True)
hints = get_type_hints(_annotation_target(func), include_extras=True)
except Exception:
return DependencyTree()

Expand Down Expand Up @@ -214,7 +219,7 @@ def solve_dependencies(
# Inject Request if the dependency declares it
if request is not None:
try:
hints = get_type_hints(use_fn)
hints = get_type_hints(_annotation_target(use_fn))
except Exception: # pragma: no cover - defensive for broken annotations
hints = {}
for param_name, annotation in hints.items():
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ def register(
A Callable
"""

def _register(func) -> Callable | None:
def _register(func) -> Callable:
if not is_valid_path(path):
warnings.warn(
f"The path `{path}` registered for `{self.kind_resolver}` is not valid and will be skipped."
Expand All @@ -53,7 +53,7 @@ def _register(func) -> Callable | None:
stacklevel=2,
category=PowertoolsUserWarning,
)
return None
return func

logger.debug(
f"Adding resolver `{func.__name__}` for path `{path}` and kind_resolver `{self.kind_resolver}`",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,6 @@ def lambda_handler(event, context):

def _get_base_path(self) -> str:
stage = self.current_event.request_context.stage
if stage and stage != "$default" and self.current_event.request_context.http.method.startswith(f"/{stage}"):
if stage and stage != "$default" and self.current_event.request_context.http.path.startswith(f"/{stage}"):
return f"/{stage}"
return ""
36 changes: 36 additions & 0 deletions tests/functional/event_handler/_pydantic/test_bedrock_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -418,3 +418,39 @@ def claims() -> dict[str, Any]:
body = result["response"]["responseBody"]["application/json"]["body"]
assert "잔액은 1,000원입니다 💰" in body
assert json.loads(body) == {"output": "잔액은 1,000원입니다 💰"}


def test_bedrock_agent_ignores_non_path_params_when_validation_is_off():
# GIVEN a Bedrock Agent event with a parameter that is not a path parameter
event = load_event("bedrockAgentEventWithPathParams.json")
event["parameters"] = [*event["parameters"], {"type": "string", "name": "verbose", "value": "true"}]

app = BedrockAgentResolver(enable_validation=False)

@app.get("/claims/<claim_id>", description="Gets claims by ID")
def claims(claim_id: str):
return {"claim_id": claim_id}

# WHEN calling the event handler
result = app(event, {})

# THEN only path parameters are injected as kwargs and the handler resolves
assert result["response"]["httpStatusCode"] == 200
body = json.loads(result["response"]["responseBody"]["application/json"]["body"])
assert body == {"claim_id": "123"}


def test_bedrock_agent_response_without_content_type_defaults_to_json():
# GIVEN a handler returning a Response without a content type
app = BedrockAgentResolver(enable_validation=False)

@app.get("/claims", description="Gets claims")
def claims():
return Response(status_code=200, body="ok")

# WHEN calling the event handler
result = app(load_event("bedrockAgentEvent.json"), {})

# THEN the response body media type defaults to application/json instead of "null"
assert "null" not in result["response"]["responseBody"]
assert result["response"]["responseBody"]["application/json"]["body"] == "ok"
Original file line number Diff line number Diff line change
Expand Up @@ -1643,3 +1643,19 @@ def handle_events():
# WHEN we resolve the event with unauthorized route
with pytest.raises(UnauthorizedException):
app.resolve(mock_event, lambda_context)


def test_invalid_path_registration_still_returns_function():
# GIVEN an AppSyncEventsResolver
app = AppSyncEventsResolver()

# WHEN registering a resolver for an invalid path
with pytest.warns(PowertoolsUserWarning, match="is not valid and will be skipped"):

@app.on_publish(path="/default/*/sub")
def handler(payload):
return payload

# THEN the decorated function is returned instead of None
assert handler is not None
assert handler("payload") == "payload"
Original file line number Diff line number Diff line change
Expand Up @@ -387,3 +387,26 @@ def create_something(id: str): # noqa AA03 VNE003

# THEN the return must be the Exception Handler error message
assert result["message"] == "error"


def test_exception_handler_clears_context():
# GIVEN an AppSyncResolver with an exception handler and a resolver that appends context
mock_event = load_event("appSyncDirectResolver.json")

app = AppSyncResolver()

@app.exception_handler(ValueError)
def handle_value_error(ex: ValueError):
return {"error": str(ex)}

@app.resolver(field_name="createSomething")
def create_something(id: str): # noqa AA03 VNE003
app.append_context(invocation="data")
raise ValueError("something went wrong")

# WHEN resolving an event that triggers the exception handler
result = app.resolve(mock_event, {})

# THEN the handler result is returned and the context does not leak into the next invocation
assert result == {"error": "something went wrong"}
assert app.context == {}
Original file line number Diff line number Diff line change
Expand Up @@ -647,6 +647,43 @@ def handler(user: Annotated[str, Depends(get_current_user)]):
assert json.loads(result["body"]) == {"statusCode": 401, "message": "Missing authentication"}


def test_depends_class_dependency_receives_request():
"""A class dependency gets Request injected into its __init__."""
app = APIGatewayHttpResolver()

class Service:
def __init__(self, request: Request):
self.path = request.resolved_event.path

@app.post("/my/path")
def handler(svc: Annotated[Service, Depends(Service)]):
return {"path": svc.path}

result = app(API_GW_V2_EVENT, {})
assert result["statusCode"] == 200
assert json.loads(result["body"]) == {"path": "/my/path"}


def test_depends_class_dependency_with_nested_depends():
"""A class dependency resolves its own Depends() parameters declared on __init__."""
app = APIGatewayHttpResolver()

def get_tenant() -> str:
return "tenant-1"

class Service:
def __init__(self, tenant: Annotated[str, Depends(get_tenant)]):
self.tenant = tenant

@app.post("/my/path")
def handler(svc: Annotated[Service, Depends(Service)]):
return {"tenant": svc.tenant}

result = app(API_GW_V2_EVENT, {})
assert result["statusCode"] == 200
assert json.loads(result["body"]) == {"tenant": "tenant-1"}


def test_depends_with_none_default_is_resolved():
"""A Depends() parameter that also declares ``= None`` is still resolved (Python 3.10 wraps it in Optional)."""
app = APIGatewayHttpResolver()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,3 +82,18 @@ def foo():
# THEN process event correctly
# AND return 404 because the event doesn't match any known route
assert result["statusCode"] == 404


def test_lambda_function_url_get_base_path_checks_http_path():
# GIVEN a Lambda Function Url event with a custom stage prefixing the path
event = load_event("lambdaFunctionUrlEvent.json")
event["requestContext"]["stage"] = "live"
event["rawPath"] = "/live/my/path"
event["requestContext"]["http"]["path"] = "/live/my/path"

app = LambdaFunctionUrlResolver()
app.current_event = LambdaFunctionUrlEvent(event)

# WHEN deriving the base path
# THEN it comes from the http path, not the http method
assert app._get_base_path() == "/live"
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
from __future__ import annotations

import pytest

from aws_lambda_powertools.event_handler import (
ALBResolver,
APIGatewayHttpResolver,
Expand Down Expand Up @@ -76,3 +78,20 @@ def foo():
app.include_router(router)
result = app(load_event("lambdaFunctionUrlEvent.json"), {})
assert result["body"] == "routed"


def test_router_warns_on_duplicate_route():
# GIVEN a Router with a registered route
router = APIGatewayRouter()

@router.get("/my/path")
def first():
return {"fn": "first"}

# WHEN registering the same route again
# THEN a warning is emitted like the resolver does
with pytest.warns(UserWarning, match="A route like this was already registered"):

@router.get("/my/path")
def second():
return {"fn": "second"}
Loading