"""Request correlation and access logging middleware.""" from __future__ import annotations import logging import time import uuid from collections.abc import Awaitable, Callable from fastapi import Request, Response from starlette.datastructures import MutableHeaders from starlette.middleware.base import BaseHTTPMiddleware from starlette.types import Message logger = logging.getLogger(__name__) RequestHandler = Callable[[Request], Awaitable[Response]] class RequestContextMiddleware(BaseHTTPMiddleware): """Attach a bounded request ID and log one structured completion event.""" def __init__(self, app: object, header_name: str) -> None: super().__init__(app) # type: ignore[arg-type] self._header_name = header_name async def dispatch(self, request: Request, call_next: RequestHandler) -> Response: supplied = request.headers.get(self._header_name, "") request_id = supplied if 0 < len(supplied) <= 128 else str(uuid.uuid4()) request.state.request_id = request_id started = time.monotonic() response = await call_next(request) response.headers[self._header_name] = request_id logger.info( "request completed", extra={ "request_id": request_id, "method": request.method, "path": request.url.path, "status": response.status_code, "duration_ms": round((time.monotonic() - started) * 1000, 3), }, ) return response class HeadAsGetMiddleware(BaseHTTPMiddleware): """Serve HEAD for every GET route (deep + stub) with an empty body. FastAPI ``add_api_route(methods=['GET'])`` and some router setups omit HEAD; contract matrix probes expect synthetic HEAD on each GET path. """ async def dispatch(self, request: Request, call_next: RequestHandler) -> Response: if request.method != "HEAD": return await call_next(request) # Replay as GET, then strip the body while preserving status/headers. request.scope["method"] = "GET" response = await call_next(request) body = bytearray() async for chunk in response.body_iterator: if isinstance(chunk, str): body.extend(chunk.encode(response.charset or "utf-8")) else: body.extend(chunk) headers = MutableHeaders(scope={"type": "http", "headers": []}) for key, value in response.headers.items(): if key.lower() in {"content-length", "content-type", "transfer-encoding"}: continue headers.append(key, value) headers["content-length"] = str(len(body)) if response.media_type: headers["content-type"] = response.media_type async def _empty_receive() -> Message: return {"type": "http.request", "body": b"", "more_body": False} del _empty_receive return Response( content=b"", status_code=response.status_code, headers=headers, media_type=response.media_type, )