"""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)