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.
75 lines
2.7 KiB
Python
Executable File
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)
|