Files
meida_backend/packages/shared/middleware.py
T
xsl 12583e9739 feat(shared): cross-cutting plumbing and pure domain layer
Typed pydantic-settings config, structlog JSON logging with request/trace id
propagation and redaction of phone numbers, tokens and face keys, the unified
response envelope and error-code table from 架构文档 §9.2, async SQLAlchemy
session management, ORM models for the eight foundation tables, and the
OpenTelemetry/Prometheus wiring.

packages/domain stays pure logic -- snowflake ids, the quota three-phase
protocol and the tk_task state machine -- so it can be tested without a
database and is held to mypy strict.
2026-08-14 00:26:39 +08:00

75 lines
2.7 KiB
Python
Executable File

"""ASGI middleware: request id, access log, metrics, /v1 path alias."""
from __future__ import annotations
import time
from collections.abc import Awaitable, Callable
from fastapi import FastAPI, Request, Response
from starlette.middleware.base import BaseHTTPMiddleware
from packages.shared.context import bind_context, new_request_id, reset_context
from packages.shared.logging import get_logger
from packages.shared.observability import HTTP_LATENCY, HTTP_REQUESTS, current_trace_id
REQUEST_ID_HEADER = "X-Request-Id"
logger = get_logger(__name__)
class RequestContextMiddleware(BaseHTTPMiddleware):
"""Bind request/trace ids, emit the access log, and record HTTP metrics."""
async def dispatch(
self, request: Request, call_next: Callable[[Request], Awaitable[Response]]
) -> Response:
request_id = request.headers.get(REQUEST_ID_HEADER) or new_request_id()
tokens = bind_context(request_id=request_id, trace_id=current_trace_id())
started = time.perf_counter()
status_code = 500
try:
response = await call_next(request)
status_code = response.status_code
response.headers[REQUEST_ID_HEADER] = request_id
return response
finally:
elapsed = time.perf_counter() - started
route = _route_template(request)
HTTP_REQUESTS.labels(request.method, route, str(status_code)).inc()
HTTP_LATENCY.labels(request.method, route).observe(elapsed)
logger.info(
"http_request",
method=request.method,
route=route,
status=status_code,
duration_ms=round(elapsed * 1000, 2),
)
reset_context(tokens)
def _route_template(request: Request) -> str:
route = request.scope.get("route")
path = getattr(route, "path", None)
return str(path) if path else request.url.path
def install_version_alias(app: FastAPI, api_prefix: str) -> None:
"""Accept ``/v1/...`` as an alias of ``/api/v1/...``.
架构文档 §9.1 fixes the base path at ``/api/v1``; several internal specs and
smoke scripts refer to ``/v1/...``. Rewriting at the ASGI scope keeps a
single set of route definitions and one OpenAPI document.
"""
legacy_prefix = "/v1"
if not api_prefix.endswith(legacy_prefix):
return
@app.middleware("http")
async def _rewrite(
request: Request, call_next: Callable[[Request], Awaitable[Response]]
) -> Response:
path = request.scope.get("path", "")
if path == legacy_prefix or path.startswith(legacy_prefix + "/"):
request.scope["path"] = api_prefix + path[len(legacy_prefix) :]
return await call_next(request)