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.
This commit is contained in:
Executable
+74
@@ -0,0 +1,74 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user