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
50 changes: 43 additions & 7 deletions aws_lambda_powertools/event_handler/api_gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -3035,21 +3035,18 @@ def include_router(self, router: Router, prefix: str | None = None) -> None:
An optional prefix to be added to the originally defined rule
"""

# Add reference to parent ApiGatewayResolver to support use cases where people subclass it to add custom logic
router.api_resolver = self

logger.debug("Merging App context with Router context")
self.context.update(**router.context)

# Delegate request state to the resolver after preserving the router context.
router.api_resolver = self

logger.debug("Appending Router middlewares into App middlewares.")
self._router_middlewares = self._router_middlewares + router._router_middlewares

logger.debug("Appending Router exception_handler into App exception_handler.")
self.exception_handler_manager.update_exception_handlers(router._exception_handlers)

# use pointer to allow context clearance after event is processed e.g., resolve(evt, ctx)
router.context = self.context

# Iterate through the routes defined in the router to configure and apply middlewares for each route
for route, func in router._routes.items():
new_route = route
Expand Down Expand Up @@ -3112,9 +3109,48 @@ def __init__(self):
self._routes: dict[tuple, Callable] = {}
self._routes_with_middleware: dict[tuple, list[Callable]] = {}
self.api_resolver: BaseRouter | None = None
self.context = {} # early init as customers might add context before event resolution
self._context: dict = {} # early init as customers might add context before event resolution
self._exception_handlers: dict[type, Callable] = {}

@property
def current_event(self) -> BaseProxyEvent:
if self.api_resolver is not None:
return self.api_resolver.current_event
return BaseRouter.current_event

@current_event.setter
def current_event(self, value: BaseProxyEvent) -> None:
if self.api_resolver is not None:
self.api_resolver.current_event = value
else:
BaseRouter.current_event = value

@property
def lambda_context(self) -> LambdaContext:
if self.api_resolver is not None:
return self.api_resolver.lambda_context
return BaseRouter.lambda_context

@lambda_context.setter
def lambda_context(self, value: LambdaContext) -> None:
if self.api_resolver is not None:
self.api_resolver.lambda_context = value
else:
BaseRouter.lambda_context = value

@property
def context(self) -> dict:
if self.api_resolver is not None:
return self.api_resolver.context
return self._context

@context.setter
def context(self, value: dict) -> None:
if self.api_resolver is not None:
self.api_resolver.context = value
else:
self._context = value

def route(
self,
rule: str,
Expand Down
81 changes: 68 additions & 13 deletions aws_lambda_powertools/event_handler/http_resolver.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
from __future__ import annotations

import base64
from contextvars import ContextVar
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, Callable
from urllib.parse import parse_qs

Expand All @@ -13,6 +15,8 @@
from aws_lambda_powertools.utilities.data_classes.common import BaseProxyEvent

if TYPE_CHECKING:
from collections.abc import Mapping, MutableMapping

from aws_lambda_powertools.shared.cookies import Cookie


Expand Down Expand Up @@ -95,7 +99,7 @@ def _from_dict(cls, data: dict[str, Any]) -> HttpProxyEvent:
return instance

@classmethod
def from_asgi(cls, scope: dict[str, Any], body: bytes | None = None) -> HttpProxyEvent:
def from_asgi(cls, scope: Mapping[str, Any], body: bytes | None = None) -> HttpProxyEvent:
"""
Create an HttpProxyEvent from an ASGI scope dict.

Expand Down Expand Up @@ -159,6 +163,14 @@ def get_remaining_time_in_millis(self) -> int: # pragma: no cover
return 300000 # 5 minutes


@dataclass
class _RequestState:
event: BaseProxyEvent | None = None
lambda_context: Any = None
context: dict = field(default_factory=dict)
processed_stack_frames: list[str] = field(default_factory=list)


class HttpResolverLocal(ApiGatewayResolver):
"""
ASGI-compatible HTTP resolver.
Expand Down Expand Up @@ -204,6 +216,8 @@ def __init__(
strip_prefixes: list[str | Any] | None = None,
enable_validation: bool = False,
):
self._startup_state = _RequestState()
self._request_state: ContextVar[_RequestState | None] = ContextVar("local_http_request", default=None)
super().__init__(
proxy_type=ProxyEventType.APIGatewayProxyEvent, # Use REST API format internally
cors=cors,
Expand All @@ -212,7 +226,46 @@ def __init__(
strip_prefixes=strip_prefixes,
enable_validation=enable_validation,
)
self._is_async_mode = False

@property
def _state(self) -> _RequestState:
return self._request_state.get() or self._startup_state

# Powertools declares these as mutable attributes. Properties preserve that
# interface while directing each task to its own state. asyncio.to_thread
# propagates the ContextVar, so middleware sees the same request dictionary.
@property
Comment thread
leandrodamascena marked this conversation as resolved.
def current_event(self) -> BaseProxyEvent:
# Preserve the inherited synchronous resolve() path outside ASGI calls.
return self._state.event or BaseRouter.current_event

@current_event.setter
def current_event(self, value: BaseProxyEvent) -> None:
self._state.event = value

@property
def lambda_context(self) -> Any:
return self._state.lambda_context or BaseRouter.lambda_context

@lambda_context.setter
def lambda_context(self, value: Any) -> None:
self._state.lambda_context = value

@property
def context(self) -> dict:
return self._state.context

@context.setter
def context(self, value: dict) -> None:
self._state.context = value

@property
def processed_stack_frames(self) -> list[str]:
return self._state.processed_stack_frames

@processed_stack_frames.setter
def processed_stack_frames(self, value: list[str]) -> None:
self._state.processed_stack_frames = value

def _to_proxy_event(self, event: dict) -> BaseProxyEvent:
"""Convert event dict to HttpProxyEvent."""
Expand All @@ -234,7 +287,7 @@ async def _resolve_async(self) -> dict: # type: ignore[override]
response_builder = await super()._resolve_async()
return response_builder.build(self.current_event, self._cors)

async def asgi_handler(self, scope: dict, receive: Callable, send: Callable) -> None:
async def asgi_handler(self, scope: MutableMapping[str, Any], receive: Callable, send: Callable) -> None:
"""
ASGI interface - allows running with uvicorn/hypercorn/etc.

Expand Down Expand Up @@ -274,25 +327,27 @@ async def asgi_handler(self, scope: dict, receive: Callable, send: Callable) ->
# Create mock Lambda context
context: Any = MockLambdaContext()

# Set up resolver state (similar to resolve())
BaseRouter.current_event = self._to_proxy_event(event._data)
BaseRouter.lambda_context = context

self._is_async_mode = True

# Never write BaseRouter's class attributes: another ASGI request may
# enter while validation or the handler is awaiting I/O.
state = _RequestState(
event=self._to_proxy_event(event._data),
lambda_context=context,
context=self._startup_state.context.copy(),
Comment thread
leandrodamascena marked this conversation as resolved.
)
token = self._request_state.set(state)
try:
# Use async resolve
response = await self._resolve_async()
finally:
self._is_async_mode = False
self.clear_context()
# Reset only this task's binding. Middleware threads may still be
# unwinding after cancellation and retain their request's state.
self._request_state.reset(token)

# Send HTTP response
await self._send_response(send, response)

async def __call__( # type: ignore[override]
self,
scope: dict,
scope: MutableMapping[str, Any],
receive: Callable,
send: Callable,
) -> None:
Expand Down
Loading