Skip to content

Commit b3a4cd3

Browse files
committed
feat(fastapi): dispatch structured RequestHandled event per HTTP request
Adds RequestLifecycleMiddleware to the FastAPI integration, registered automatically by FastAPIProvider. For every request it fires a RequestHandled event (method, path, status_code, duration_ms, request_id) through the existing Event dispatcher/facade — no new event mechanism, no storage, no UI. A request id is generated per request (or echoed back if the client already sent one) and returned via the X-Request-Id header.
1 parent 22ea9cd commit b3a4cd3

5 files changed

Lines changed: 185 additions & 1 deletion

File tree

fastapi_startkit/src/fastapi_startkit/fastapi/__init__.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,14 @@
22
from .routers.router import Router
33
from .requests.model import RequestModel
44
from .config import FastAPIConfig
5+
from .events import RequestHandled
6+
from .middleware import RequestLifecycleMiddleware
57

6-
__all__ = ["FastAPIProvider", "Router", "RequestModel", "FastAPIConfig"]
8+
__all__ = [
9+
"FastAPIProvider",
10+
"Router",
11+
"RequestModel",
12+
"FastAPIConfig",
13+
"RequestHandled",
14+
"RequestLifecycleMiddleware",
15+
]
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
"""Structured events emitted by the FastAPI request lifecycle.
2+
3+
These ride on the framework's Laravel-style event dispatcher (see
4+
``fastapi_startkit.events``) — this module only defines the event payload,
5+
it does not dispatch, log, or persist anything itself.
6+
"""
7+
8+
from dataclasses import dataclass
9+
10+
11+
@dataclass
12+
class RequestHandled:
13+
"""Dispatched once per HTTP request by ``RequestLifecycleMiddleware``.
14+
15+
Register a listener the normal way to observe it::
16+
17+
from fastapi_startkit.facades import Event
18+
from fastapi_startkit.fastapi.events import RequestHandled
19+
20+
Event.listen(RequestHandled, lambda e: logger.info(
21+
"%s %s -> %s (%.1fms) [%s]", e.method, e.path, e.status_code, e.duration_ms, e.request_id
22+
))
23+
"""
24+
25+
method: str
26+
path: str
27+
status_code: int
28+
duration_ms: float
29+
request_id: str
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
"""ASGI middleware that emits a structured ``RequestHandled`` event per request.
2+
3+
This is dispatch-only: it fires the event through the framework's existing
4+
Event dispatcher (task #940) and does not persist, log, or render anything
5+
itself. Consumers — a logging listener, tests, future tooling — register
6+
listeners the normal way via ``Event.listen(RequestHandled, ...)``.
7+
8+
Out of scope for this middleware (left as extension points for follow-up
9+
work): DB query watchers, exception watchers, and any other Telescope-style
10+
watcher, plus storage/UI for the emitted events.
11+
"""
12+
13+
import time
14+
import uuid
15+
16+
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
17+
from starlette.requests import Request
18+
from starlette.responses import Response
19+
20+
from fastapi_startkit.events.helpers import event
21+
22+
from .events import RequestHandled
23+
24+
REQUEST_ID_HEADER = "X-Request-Id"
25+
26+
27+
class RequestLifecycleMiddleware(BaseHTTPMiddleware):
28+
"""Dispatches ``RequestHandled`` (method, path, status, duration, request id)."""
29+
30+
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
31+
request_id = request.headers.get(REQUEST_ID_HEADER) or str(uuid.uuid4())
32+
request.state.request_id = request_id
33+
34+
started_at = time.perf_counter()
35+
response = None
36+
try:
37+
response = await call_next(request)
38+
return response
39+
finally:
40+
duration_ms = (time.perf_counter() - started_at) * 1000
41+
status_code = response.status_code if response is not None else 500
42+
43+
if response is not None:
44+
response.headers.setdefault(REQUEST_ID_HEADER, request_id)
45+
46+
await event(
47+
RequestHandled(
48+
method=request.method,
49+
path=request.url.path,
50+
status_code=status_code,
51+
duration_ms=duration_ms,
52+
request_id=request_id,
53+
)
54+
)

fastapi_startkit/src/fastapi_startkit/fastapi/providers/fastapi_provider.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
from fastapi_startkit.fastapi.commands import ServeCommand
55
from fastapi_startkit.fastapi.config import FastAPIConfig
6+
from fastapi_startkit.fastapi.middleware import RequestLifecycleMiddleware
67
from fastapi_startkit.support import Provider
78

89

@@ -26,6 +27,7 @@ def boot(self):
2627

2728
self.commands([ServeCommand])
2829
self._register_exception_handlers()
30+
self.app.add_middleware(RequestLifecycleMiddleware)
2931

3032
source = os.path.abspath(os.path.join(os.path.dirname(__file__), "../config/fastapi.py"))
3133
self.publishes({source: "config/fastapi.py"})
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
"""Tests for RequestLifecycleMiddleware — structured RequestHandled events (task #1324)."""
2+
3+
import tempfile
4+
import unittest
5+
from pathlib import Path
6+
7+
from fastapi.responses import JSONResponse
8+
from fastapi.testclient import TestClient
9+
10+
from fastapi_startkit.application import Application
11+
from fastapi_startkit.container.container import Container
12+
from fastapi_startkit.facades import Event
13+
from fastapi_startkit.fastapi import FastAPIProvider, RequestHandled
14+
from fastapi_startkit.fastapi.middleware import REQUEST_ID_HEADER
15+
16+
17+
def make_client(app: Application) -> TestClient:
18+
@app.fastapi.get("/ping")
19+
def ping():
20+
return {"ok": True}
21+
22+
@app.fastapi.get("/boom")
23+
def boom():
24+
return JSONResponse(status_code=404, content={"detail": "not found"})
25+
26+
return TestClient(app.fastapi)
27+
28+
29+
class RequestLifecycleEventTest(unittest.TestCase):
30+
def setUp(self):
31+
self._tmp_dir = tempfile.TemporaryDirectory()
32+
self.addCleanup(self._tmp_dir.cleanup)
33+
self._original_container_instance = Container._instance
34+
self.addCleanup(self._restore_container)
35+
36+
self.app = Application(base_path=Path(self._tmp_dir.name), env="testing", providers=[FastAPIProvider])
37+
self.client = make_client(self.app)
38+
39+
def _restore_container(self):
40+
Container._instance = self._original_container_instance
41+
42+
def test_dispatches_request_handled_event(self):
43+
seen = []
44+
Event.listen(RequestHandled, lambda e: seen.append(e))
45+
46+
response = self.client.get("/ping")
47+
48+
self.assertEqual(response.status_code, 200)
49+
self.assertEqual(len(seen), 1)
50+
event = seen[0]
51+
self.assertEqual(event.method, "GET")
52+
self.assertEqual(event.path, "/ping")
53+
self.assertEqual(event.status_code, 200)
54+
self.assertGreaterEqual(event.duration_ms, 0)
55+
self.assertTrue(event.request_id)
56+
57+
def test_event_reflects_error_status_code(self):
58+
seen = []
59+
Event.listen(RequestHandled, lambda e: seen.append(e))
60+
61+
response = self.client.get("/boom")
62+
63+
self.assertEqual(response.status_code, 404)
64+
self.assertEqual(seen[0].status_code, 404)
65+
66+
def test_response_carries_generated_request_id_header(self):
67+
response = self.client.get("/ping")
68+
69+
self.assertIn(REQUEST_ID_HEADER, response.headers)
70+
self.assertTrue(response.headers[REQUEST_ID_HEADER])
71+
72+
def test_incoming_request_id_is_echoed_back(self):
73+
response = self.client.get("/ping", headers={REQUEST_ID_HEADER: "client-supplied-id"})
74+
75+
self.assertEqual(response.headers[REQUEST_ID_HEADER], "client-supplied-id")
76+
77+
seen = []
78+
Event.listen(RequestHandled, lambda e: seen.append(e))
79+
self.client.get("/ping", headers={REQUEST_ID_HEADER: "another-id"})
80+
self.assertEqual(seen[0].request_id, "another-id")
81+
82+
def test_event_fake_records_without_invoking_real_listener(self):
83+
called = []
84+
Event.listen(RequestHandled, lambda e: called.append(e))
85+
86+
fake = Event.fake()
87+
self.client.get("/ping")
88+
89+
self.assertEqual(called, [])
90+
fake.assert_dispatched(RequestHandled, lambda e: e.path == "/ping" and e.status_code == 200)

0 commit comments

Comments
 (0)