Skip to content

Commit 9ec0f66

Browse files
committed
feat: allow custom handlers to define their own cycle class via cycle_cls property
1 parent 033d165 commit 9ec0f66

File tree

8 files changed

+207
-9
lines changed

8 files changed

+207
-9
lines changed

docs/adapter.md

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,3 +82,101 @@ def hello(request: Request):
8282

8383
handler = Mangum(app)
8484
```
85+
86+
## Custom Handlers
87+
88+
Mangum supports custom handlers to process Lambda events that don't match the built-in handlers (API Gateway, ALB, Lambda@Edge). This is useful for handling custom event formats or integrating with other AWS services.
89+
90+
### Basic Custom Handler
91+
92+
A custom handler must implement the following interface:
93+
94+
```python
95+
from mangum import Mangum
96+
from mangum.protocols import HTTPCycle
97+
from mangum.types import Cycle, LambdaConfig, LambdaContext, LambdaEvent, Response, Scope
98+
99+
100+
class MyCustomHandler:
101+
@classmethod
102+
def infer(cls, event: LambdaEvent, context: LambdaContext, config: LambdaConfig) -> bool:
103+
"""Return True if this handler should process the event."""
104+
return "my-custom-key" in event
105+
106+
def __init__(self, event: LambdaEvent, context: LambdaContext, config: LambdaConfig) -> None:
107+
self.event = event
108+
self.context = context
109+
self.config = config
110+
111+
@property
112+
def cycle_cls(self) -> type[Cycle]:
113+
"""Return the cycle class to use for request/response processing."""
114+
return HTTPCycle
115+
116+
@property
117+
def body(self) -> bytes:
118+
"""Return the request body."""
119+
return self.event.get("body", b"")
120+
121+
@property
122+
def scope(self) -> Scope:
123+
"""Return the ASGI scope dictionary."""
124+
return {
125+
"type": "http",
126+
"http_version": "1.1",
127+
"method": self.event.get("method", "GET"),
128+
"headers": [],
129+
"path": self.event.get("path", "/"),
130+
"raw_path": None,
131+
"root_path": "",
132+
"scheme": "https",
133+
"query_string": b"",
134+
"server": ("localhost", 443),
135+
"client": ("127.0.0.1", 0),
136+
"asgi": {"version": "3.0", "spec_version": "2.0"},
137+
"aws.event": self.event,
138+
"aws.context": self.context,
139+
}
140+
141+
def __call__(self, response: Response) -> dict:
142+
"""Transform the ASGI response to a Lambda response."""
143+
return {
144+
"statusCode": response["status"],
145+
"headers": {k.decode(): v.decode() for k, v in response["headers"]},
146+
"body": response["body"].decode(),
147+
}
148+
149+
150+
handler = Mangum(app, custom_handlers=[MyCustomHandler])
151+
```
152+
153+
Custom handlers are checked **before** the built-in handlers, so they take priority.
154+
155+
### Custom Protocol Cycle
156+
157+
The `cycle_cls` property allows you to specify a custom request/response cycle class. This is useful for implementing custom protocols or adding middleware-like behavior at the cycle level.
158+
159+
```python
160+
from mangum.protocols import HTTPCycle
161+
from mangum.types import ASGI, Cycle, Response, Scope
162+
163+
164+
class MyCustomCycle:
165+
"""A custom cycle that adds behavior to the standard HTTP cycle."""
166+
167+
def __init__(self, scope: Scope, body: bytes) -> None:
168+
self.scope = scope
169+
self._http_cycle = HTTPCycle(scope, body)
170+
171+
async def __call__(self, app: ASGI) -> Response:
172+
# Add custom logic before/after the request
173+
return await self._http_cycle(app)
174+
175+
176+
class MyCustomHandler:
177+
# ... other methods ...
178+
179+
@property
180+
def cycle_cls(self) -> type[Cycle]:
181+
return MyCustomCycle
182+
```

mangum/adapter.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from mangum._compat import asyncio_run
88
from mangum.exceptions import ConfigurationError
99
from mangum.handlers import ALB, APIGateway, HTTPGateway, LambdaAtEdge
10-
from mangum.protocols import HTTPCycle, LifespanCycle
10+
from mangum.protocols import LifespanCycle
1111
from mangum.types import ASGI, LambdaConfig, LambdaContext, LambdaEvent, LambdaHandler, LifespanMode
1212

1313
logger = logging.getLogger("mangum")
@@ -67,12 +67,12 @@ async def handle_request() -> dict[str, Any]:
6767
lifespan_cycle = LifespanCycle(self.app, self.lifespan)
6868
async with lifespan_cycle:
6969
scope.update({"state": lifespan_cycle.lifespan_state.copy()})
70-
http_cycle = HTTPCycle(scope, handler.body)
71-
http_response = await http_cycle(self.app)
72-
return handler(http_response)
70+
cycle = handler.cycle_cls(scope, handler.body)
71+
response = await cycle(self.app)
72+
return handler(response)
7373
else:
74-
http_cycle = HTTPCycle(scope, handler.body)
75-
http_response = await http_cycle(self.app)
76-
return handler(http_response)
74+
cycle = handler.cycle_cls(scope, handler.body)
75+
response = await cycle(self.app)
76+
return handler(response)
7777

7878
return asyncio_run(handle_request())

mangum/handlers/alb.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@
1111
handle_exclude_headers,
1212
maybe_encode_body,
1313
)
14+
from mangum.protocols import HTTPCycle
1415
from mangum.types import (
16+
Cycle,
1517
LambdaConfig,
1618
LambdaContext,
1719
LambdaEvent,
@@ -94,6 +96,10 @@ def __init__(self, event: LambdaEvent, context: LambdaContext, config: LambdaCon
9496
self.context = context
9597
self.config = config
9698

99+
@property
100+
def cycle_cls(self) -> type[Cycle]:
101+
return HTTPCycle
102+
97103
@property
98104
def body(self) -> bytes:
99105
return maybe_encode_body(

mangum/handlers/api_gateway.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@
1111
maybe_encode_body,
1212
strip_api_gateway_path,
1313
)
14+
from mangum.protocols import HTTPCycle
1415
from mangum.types import (
16+
Cycle,
1517
Headers,
1618
LambdaConfig,
1719
LambdaContext,
@@ -74,6 +76,10 @@ def __init__(self, event: LambdaEvent, context: LambdaContext, config: LambdaCon
7476
self.context = context
7577
self.config = config
7678

79+
@property
80+
def cycle_cls(self) -> type[Cycle]:
81+
return HTTPCycle
82+
7783
@property
7884
def body(self) -> bytes:
7985
return maybe_encode_body(
@@ -132,6 +138,10 @@ def __init__(self, event: LambdaEvent, context: LambdaContext, config: LambdaCon
132138
self.context = context
133139
self.config = config
134140

141+
@property
142+
def cycle_cls(self) -> type[Cycle]:
143+
return HTTPCycle
144+
135145
@property
136146
def body(self) -> bytes:
137147
return maybe_encode_body(

mangum/handlers/lambda_at_edge.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,15 @@
88
handle_multi_value_headers,
99
maybe_encode_body,
1010
)
11-
from mangum.types import LambdaConfig, LambdaContext, LambdaEvent, Response, Scope
11+
from mangum.protocols import HTTPCycle
12+
from mangum.types import (
13+
Cycle,
14+
LambdaConfig,
15+
LambdaContext,
16+
LambdaEvent,
17+
Response,
18+
Scope,
19+
)
1220

1321

1422
class LambdaAtEdge:
@@ -25,6 +33,10 @@ def __init__(self, event: LambdaEvent, context: LambdaContext, config: LambdaCon
2533
self.context = context
2634
self.config = config
2735

36+
@property
37+
def cycle_cls(self) -> type[Cycle]:
38+
return HTTPCycle
39+
2840
@property
2941
def body(self) -> bytes:
3042
cf_request_body = self.event["Records"][0]["cf"]["request"].get("body", {})

mangum/types.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,12 +110,27 @@ class LambdaConfig(TypedDict):
110110
exclude_headers: list[str]
111111

112112

113+
class Cycle(Protocol):
114+
def __init__(self, scope: Scope, body: bytes) -> None: ... # pragma: no cover
115+
116+
async def __call__(self, app: ASGI) -> Response: ... # pragma: no cover
117+
118+
async def run(self, app: ASGI) -> None: ... # pragma: no cover
119+
120+
async def receive(self) -> Message: ... # pragma: no cover
121+
122+
async def send(self, message: Message) -> None: ... # pragma: no cover
123+
124+
113125
class LambdaHandler(Protocol):
114126
def __init__(self, *args: Any) -> None: ... # pragma: no cover
115127

116128
@classmethod
117129
def infer(cls, event: LambdaEvent, context: LambdaContext, config: LambdaConfig) -> bool: ... # pragma: no cover
118130

131+
@property
132+
def cycle_cls(self) -> type[Cycle]: ... # pragma: no cover
133+
119134
@property
120135
def body(self) -> bytes: ... # pragma: no cover
121136

tests/handlers/test_custom.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,3 +81,60 @@ def test_custom_handler_call():
8181

8282
result = handler(status=200, headers=[], body=b"Hello, World!")
8383
assert result == {"statusCode": 200, "headers": {}, "body": "Hello, World!"}
84+
85+
86+
def test_custom_handler_with_custom_cycle_cls():
87+
"""Test that a handler can define a custom cycle_cls property."""
88+
from mangum import Mangum
89+
from mangum.protocols import HTTPCycle
90+
from mangum.types import Cycle, Response
91+
92+
class HandlerWithCustomCycle:
93+
@classmethod
94+
def infer(cls, event, context, config):
95+
return "custom-cycle" in event
96+
97+
def __init__(self, event, context, config):
98+
self.event = event
99+
self.context = context
100+
self.config = config
101+
102+
@property
103+
def cycle_cls(self) -> type[Cycle]:
104+
return HTTPCycle
105+
106+
@property
107+
def body(self) -> bytes:
108+
return b""
109+
110+
@property
111+
def scope(self) -> Scope:
112+
return {
113+
"type": "http",
114+
"http_version": "1.1",
115+
"method": "GET",
116+
"headers": [],
117+
"path": "/",
118+
"raw_path": None,
119+
"root_path": "",
120+
"scheme": "https",
121+
"query_string": b"",
122+
"server": ("localhost", 8080),
123+
"client": ("127.0.0.1", 0),
124+
"asgi": {"version": "3.0", "spec_version": "2.0"},
125+
"aws.event": self.event,
126+
"aws.context": self.context,
127+
}
128+
129+
def __call__(self, response: Response) -> dict:
130+
return {"statusCode": response["status"], "body": response["body"].decode()}
131+
132+
async def app(scope, receive, send):
133+
await send({"type": "http.response.start", "status": 200, "headers": []})
134+
await send({"type": "http.response.body", "body": b"OK"})
135+
136+
handler = Mangum(app, lifespan="off", custom_handlers=[HandlerWithCustomCycle])
137+
response = handler({"custom-cycle": True}, {})
138+
139+
assert response["statusCode"] == 200
140+
assert response["body"] == "OK"

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)