Skip to content
Draft
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
19 changes: 19 additions & 0 deletions aws_lambda_powertools/event_router/__init__.py
Original file line number Diff line number Diff line change
@@ -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",
]
102 changes: 102 additions & 0 deletions aws_lambda_powertools/event_router/base.py
Original file line number Diff line number Diff line change
@@ -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."""
70 changes: 70 additions & 0 deletions aws_lambda_powertools/event_router/eventbridge.py
Original file line number Diff line number Diff line change
@@ -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)
14 changes: 14 additions & 0 deletions aws_lambda_powertools/event_router/exceptions.py
Original file line number Diff line number Diff line change
@@ -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."""
44 changes: 44 additions & 0 deletions docs/utilities/event_router.md
Original file line number Diff line number Diff line change
@@ -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.
16 changes: 16 additions & 0 deletions examples/event_router/src/eventbridge_exact_match.py
Original file line number Diff line number Diff line change
@@ -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)
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
100 changes: 100 additions & 0 deletions tests/unit/event_router/test_eventbridge.py
Original file line number Diff line number Diff line change
@@ -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()