diff --git a/aws_lambda_powertools/event_router/__init__.py b/aws_lambda_powertools/event_router/__init__.py new file mode 100644 index 00000000000..e301f10cbee --- /dev/null +++ b/aws_lambda_powertools/event_router/__init__.py @@ -0,0 +1,19 @@ +"""Experimental content-based routing for asynchronous Lambda events.""" + +from aws_lambda_powertools.event_router.base import BaseEventRouter +from aws_lambda_powertools.event_router.eventbridge import EventBridgeRouter +from aws_lambda_powertools.event_router.exceptions import ( + EventRouterError, + EventRouterNotFoundError, + EventRouterRegistrationError, + EventShapeMismatchError, +) + +__all__ = [ + "BaseEventRouter", + "EventBridgeRouter", + "EventRouterError", + "EventRouterNotFoundError", + "EventRouterRegistrationError", + "EventShapeMismatchError", +] diff --git a/aws_lambda_powertools/event_router/base.py b/aws_lambda_powertools/event_router/base.py new file mode 100644 index 00000000000..2457d715568 --- /dev/null +++ b/aws_lambda_powertools/event_router/base.py @@ -0,0 +1,102 @@ +"""Event-source-independent routing primitives.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from collections.abc import Callable +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, Generic, TypeVar + +from aws_lambda_powertools.event_router.exceptions import ( + EventRouterNotFoundError, + EventRouterRegistrationError, +) + +if TYPE_CHECKING: + from collections.abc import Mapping + + from aws_lambda_powertools.utilities.typing import LambdaContext + +EventT = TypeVar("EventT") +Handler = Callable[[EventT], Any] + + +@dataclass(frozen=True) +class _Route(Generic[EventT]): + matchers: Mapping[str, str] + handler: Handler[EventT] + registration_order: int + + +class BaseEventRouter(ABC, Generic[EventT]): + """Shared exact-match registration and dispatch for event-source routers. + + Concrete routers validate their event shape, extract route fields, and wrap + the raw event in an Event Source Data Class. + """ + + def __init__(self) -> None: + self._routes: list[_Route[EventT]] = [] + + def _register_route(self, matchers: Mapping[str, str]) -> Callable[[Handler[EventT]], Handler[EventT]]: + if not matchers: + raise EventRouterRegistrationError("A route must define at least one matcher") + if any(not isinstance(value, str) for value in matchers.values()): + raise EventRouterRegistrationError("Route matcher values must be strings") + + def register(handler: Handler[EventT]) -> Handler[EventT]: + if any(route.matchers == matchers for route in self._routes): + raise EventRouterRegistrationError(f"A route with matchers {dict(matchers)!r} is already registered") + self._routes.append(_Route(dict(matchers), handler, len(self._routes))) + return handler + + return register + + def resolve(self, event: Any, context: LambdaContext | None = None) -> Any: + """Route an event to one handler and return the handler result. + + Parameters + ---------- + event: + Raw Lambda event. + context: + Lambda context. Reserved for router integrations; handlers in this + experimental slice receive only the typed event. + + Returns + ------- + Any + The matched handler's unmodified return value. + + Raises + ------ + EventRouterNotFoundError + If no registered route exactly matches the extracted event fields. + """ + del context + self._validate_event_shape(event) + fields = self._extract_fields(event) + matching_routes = [ + route for route in self._routes if all(fields.get(name) == value for name, value in route.matchers.items()) + ] + if not matching_routes: + attempted = [dict(route.matchers) for route in self._routes] + raise EventRouterNotFoundError( + f"No route matched extracted fields {dict(fields)!r}; attempted routes: {attempted!r}", + ) + + # More constrained exact routes win; registration order is the stable tie-breaker. + route = min(matching_routes, key=lambda candidate: (-len(candidate.matchers), candidate.registration_order)) + return route.handler(self._wrap_event(event)) + + @abstractmethod + def _validate_event_shape(self, event: Any) -> None: + """Raise when the raw event does not match the concrete source.""" + + @abstractmethod + def _extract_fields(self, event: Any) -> Mapping[str, str]: + """Extract fields available to exact-match routes.""" + + @abstractmethod + def _wrap_event(self, event: Any) -> EventT: + """Wrap the raw event in the source-specific data class.""" diff --git a/aws_lambda_powertools/event_router/eventbridge.py b/aws_lambda_powertools/event_router/eventbridge.py new file mode 100644 index 00000000000..bc664b985e2 --- /dev/null +++ b/aws_lambda_powertools/event_router/eventbridge.py @@ -0,0 +1,70 @@ +"""Exact-match routing for Amazon EventBridge events.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import TYPE_CHECKING + +from aws_lambda_powertools.event_router.base import BaseEventRouter +from aws_lambda_powertools.event_router.exceptions import EventRouterRegistrationError, EventShapeMismatchError +from aws_lambda_powertools.utilities.data_classes import EventBridgeEvent + +if TYPE_CHECKING: + from collections.abc import Callable + from typing import Any + + from aws_lambda_powertools.event_router.base import Handler + + +class EventBridgeRouter(BaseEventRouter[EventBridgeEvent]): + """Route EventBridge events by exact ``source`` and ``detail-type`` values. + + Examples + -------- + **Route an order event** + + >>> from aws_lambda_powertools.event_router import EventBridgeRouter + >>> app = EventBridgeRouter() + >>> @app.route(source="order.service", detail_type="OrderCreated") + ... def created(event): + ... return event.detail["order_id"] + """ + + def route( + self, + *, + source: str | None = None, + detail_type: str | None = None, + ) -> Callable[[Handler[EventBridgeEvent]], Handler[EventBridgeEvent]]: + """Register a handler using exact EventBridge field matches. + + At least one matcher is required. When multiple routes match, the route + with the most specified fields wins; equally specific routes use first + registration as a stable tie-breaker. + """ + matchers = {} + if source is not None: + matchers["source"] = source + if detail_type is not None: + matchers["detail_type"] = detail_type + if not matchers: + raise EventRouterRegistrationError("EventBridge routes require source and/or detail_type") + return self._register_route(matchers) + + def _validate_event_shape(self, event: Any) -> None: + if not isinstance(event, dict): + raise EventShapeMismatchError("EventBridge event must be a dictionary") + + missing = [field for field in ("source", "detail-type", "detail") if field not in event] + if missing: + raise EventShapeMismatchError(f"EventBridge event is missing required fields: {', '.join(missing)}") + if not isinstance(event["source"], str) or not isinstance(event["detail-type"], str): + raise EventShapeMismatchError("EventBridge source and detail-type must be strings") + if not isinstance(event["detail"], Mapping): + raise EventShapeMismatchError("EventBridge detail must be an object") + + def _extract_fields(self, event: Any) -> Mapping[str, str]: + return {"source": event["source"], "detail_type": event["detail-type"]} + + def _wrap_event(self, event: Any) -> EventBridgeEvent: + return EventBridgeEvent(event) diff --git a/aws_lambda_powertools/event_router/exceptions.py b/aws_lambda_powertools/event_router/exceptions.py new file mode 100644 index 00000000000..9309c4a8b0d --- /dev/null +++ b/aws_lambda_powertools/event_router/exceptions.py @@ -0,0 +1,14 @@ +class EventRouterError(Exception): + """Base exception for the experimental event router.""" + + +class EventRouterNotFoundError(EventRouterError): + """Raised when an event does not match a registered route.""" + + +class EventShapeMismatchError(EventRouterError): + """Raised when an event does not have the shape expected by a router.""" + + +class EventRouterRegistrationError(EventRouterError): + """Raised when an event route cannot be registered.""" diff --git a/docs/utilities/event_router.md b/docs/utilities/event_router.md new file mode 100644 index 00000000000..4649855be61 --- /dev/null +++ b/docs/utilities/event_router.md @@ -0,0 +1,44 @@ +--- +title: Event Router (experimental) +description: Exact-match routing for asynchronous events +--- + +# Event Router + +!!! warning "Experimental RFC slice" + Event Router is an experimental, incomplete implementation of [RFC #8287](https://github.com/aws-powertools/powertools-lambda-python/issues/8287). Its API may change. This slice supports only exact matching for EventBridge events. + +Event Router dispatches asynchronous Lambda events by content without adding HTTP request or response semantics. `EventBridgeRouter` wraps matched events in the existing `EventBridgeEvent` data class, so it has no additional dependency. + +## Getting started + +Register handlers with one or both EventBridge fields. A handler receives the typed event, and its return value is propagated by `resolve`. + +```python +--8<-- "examples/event_router/src/eventbridge_exact_match.py" +``` + +Matching is case-sensitive and exact. When multiple routes match, the route with more fields wins. Registration order breaks ties deterministically. Registering the same matcher set twice raises `EventRouterRegistrationError`. + +## Error behavior + +`resolve` fails explicitly: + +* `EventShapeMismatchError` means the input is not an EventBridge object with string `source` and `detail-type` fields and an object `detail` field. +* `EventRouterNotFoundError` means the event is valid but no route matches. Its message includes extracted fields and attempted routes. +* Exceptions and return values from a matched handler pass through unchanged. + +## Deferred RFC scope + +This vertical slice intentionally does **not** implement: + +* glob or regular-expression matching +* S3, SQS, SNS, DynamoDB Streams, or Kinesis routers +* default/fallback handlers +* Pydantic model validation +* async handler execution +* nested EventBridge `detail` matching +* Batch Processor composition or per-record routing +* router composition and debug logging + +Batch failure handling, retries, event transformation, and idempotency remain outside the router as proposed by the RFC. diff --git a/examples/event_router/src/eventbridge_exact_match.py b/examples/event_router/src/eventbridge_exact_match.py new file mode 100644 index 00000000000..1e425878e2e --- /dev/null +++ b/examples/event_router/src/eventbridge_exact_match.py @@ -0,0 +1,16 @@ +from typing import Any + +from aws_lambda_powertools.event_router import EventBridgeRouter +from aws_lambda_powertools.utilities.data_classes import EventBridgeEvent +from aws_lambda_powertools.utilities.typing import LambdaContext + +app = EventBridgeRouter() + + +@app.route(source="order.service", detail_type="OrderCreated") +def handle_order_created(event: EventBridgeEvent) -> dict[str, Any]: + return {"order_id": event.detail["order_id"], "handled": True} + + +def lambda_handler(event: dict[str, Any], context: LambdaContext) -> Any: + return app.resolve(event, context) diff --git a/mkdocs.yml b/mkdocs.yml index 265560a55d5..ea98a6a6632 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -31,6 +31,7 @@ nav: - utilities/typing.md - utilities/validation.md - utilities/data_classes.md + - utilities/event_router.md - utilities/parser.md - utilities/idempotency.md - utilities/circuit_breaker.md diff --git a/tests/unit/event_router/test_eventbridge.py b/tests/unit/event_router/test_eventbridge.py new file mode 100644 index 00000000000..ce611a1a396 --- /dev/null +++ b/tests/unit/event_router/test_eventbridge.py @@ -0,0 +1,100 @@ +from __future__ import annotations + +from typing import Any + +import pytest + +from aws_lambda_powertools.event_router import ( + EventBridgeRouter, + EventRouterNotFoundError, + EventRouterRegistrationError, + EventShapeMismatchError, +) +from aws_lambda_powertools.utilities.data_classes import EventBridgeEvent + + +def event(source: str = "order.service", detail_type: str = "OrderCreated") -> dict[str, Any]: + return {"source": source, "detail-type": detail_type, "detail": {"order_id": "123"}} + + +def test_dispatches_exact_match_and_propagates_handler_result(): + router = EventBridgeRouter() + received: list[EventBridgeEvent] = [] + + @router.route(source="order.service", detail_type="OrderCreated") + def handle(created_event: EventBridgeEvent) -> str: + received.append(created_event) + return created_event.detail["order_id"] + + assert router.resolve(event(), None) == "123" + assert len(received) == 1 + assert isinstance(received[0], EventBridgeEvent) + + +def test_more_specific_route_wins_regardless_of_registration_order(): + router = EventBridgeRouter() + + @router.route(source="order.service") + def broad(_: EventBridgeEvent) -> str: + return "broad" + + @router.route(source="order.service", detail_type="OrderCreated") + def specific(_: EventBridgeEvent) -> str: + return "specific" + + assert router.resolve(event()) == "specific" + + +def test_no_exact_match_raises_with_diagnostics_and_does_not_call_handler(): + router = EventBridgeRouter() + called = False + + @router.route(detail_type="OrderCreated") + def handle(_: EventBridgeEvent) -> None: + nonlocal called + called = True + + with pytest.raises(EventRouterNotFoundError, match="OrderCreated"): + router.resolve(event(detail_type="ordercreated")) + + assert called is False + + +@pytest.mark.parametrize( + "malformed", + [ + None, + [], + {}, + {"source": "order.service", "detail-type": "OrderCreated"}, + {"source": 1, "detail-type": "OrderCreated", "detail": {}}, + {"source": "order.service", "detail-type": "OrderCreated", "detail": "invalid"}, + ], +) +def test_malformed_event_raises_shape_mismatch(malformed: Any): + with pytest.raises(EventShapeMismatchError): + EventBridgeRouter().resolve(malformed) + + +def test_handler_exception_propagates(): + router = EventBridgeRouter() + + @router.route(source="order.service") + def handle(_: EventBridgeEvent) -> None: + raise RuntimeError("handler failed") + + with pytest.raises(RuntimeError, match="handler failed"): + router.resolve(event()) + + +def test_duplicate_registration_is_rejected(): + router = EventBridgeRouter() + router.route(source="order.service")(lambda _: None) + + with pytest.raises(EventRouterRegistrationError, match="already registered"): + router.route(source="order.service")(lambda _: None) + + +def test_route_requires_a_matcher(): + with pytest.raises(EventRouterRegistrationError, match="require"): + EventBridgeRouter().route()