M-Bus Gateway
← Tilbage til blog
· FastAPI· middleware· Python· logging· auth· backend· observability

FastAPI middleware mønstre — logging, auth og timing

FastAPI custom middleware: request ID injection, struktureret logging med structlog, timing middleware, tenant-context fra JWT, CORS og security headers, error response standardisering.

Af M-Bus Gateway

Middleware i FastAPI håndterer tværgående funktioner: request-ID, logging, auth-context og sikkerhedsheaders. Her er de produktionsklare mønstre.


Request ID og struktureret logging

# server/src/middleware/logging.py

import uuid
import time
import structlog
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import Response

log = structlog.get_logger()


class RequestLoggingMiddleware(BaseHTTPMiddleware):
    """
    Injicer request_id i alle requests.
    Logger request + response med timing og tenant-context.
    """
    async def dispatch(self, request: Request, call_next) -> Response:
        request_id = str(uuid.uuid4())
        start = time.monotonic()

        # Bind request_id til structlog context:
        structlog.contextvars.bind_contextvars(
            request_id=request_id,
            method=request.method,
            path=request.url.path,
        )

        # Videresend request_id til downstream services:
        request.state.request_id = request_id

        try:
            response = await call_next(request)
        except Exception as exc:
            log.error(
                "request.unhandled_exception",
                exc_type=type(exc).__name__,
                error=str(exc),
            )
            raise
        finally:
            duration_ms = (time.monotonic() - start) * 1000
            log.info(
                "request.complete",
                status_code=response.status_code if 'response' in dir() else 500,
                duration_ms=round(duration_ms, 2),
            )
            structlog.contextvars.clear_contextvars()

        response.headers["X-Request-ID"] = request_id
        return response

Tenant-context middleware (JWT)

# server/src/middleware/tenant.py

from starlette.middleware.base import BaseHTTPMiddleware
from jose import jwt, JWTError
from contextvars import ContextVar

_current_tenant: ContextVar[str | None] = ContextVar("current_tenant", default=None)
_current_role: ContextVar[str | None] = ContextVar("current_role", default=None)


def get_current_tenant_id() -> str | None:
    return _current_tenant.get()


class TenantContextMiddleware(BaseHTTPMiddleware):
    """
    Udpak tenant_id fra JWT og sæt i ContextVar.
    Bruges af logging og metrics — IKKE til authorization (brug Depends()).
    """
    SKIP_PATHS = {"/health", "/metrics", "/api/v1/auth/login"}

    async def dispatch(self, request: Request, call_next) -> Response:
        if request.url.path in self.SKIP_PATHS:
            return await call_next(request)

        auth = request.headers.get("Authorization", "")
        if auth.startswith("Bearer "):
            try:
                token = auth[7:]
                payload = jwt.decode(
                    token,
                    settings.jwt_secret.get_secret_value(),
                    algorithms=["HS256"],
                    options={"verify_exp": False},    # Exp verificeres i Depends()
                )
                tenant_id = payload.get("tenant_id")
                role = payload.get("role")

                token_t = _current_tenant.set(tenant_id)
                token_r = _current_role.set(role)

                # Bind til structlog:
                structlog.contextvars.bind_contextvars(
                    tenant_id=tenant_id,
                    role=role,
                )
            except JWTError:
                pass    # Lad auth Depends() håndtere ugyldige tokens

        try:
            return await call_next(request)
        finally:
            _current_tenant.set(None)
            _current_role.set(None)

Timing middleware

# server/src/middleware/timing.py

import time
from starlette.middleware.base import BaseHTTPMiddleware

SLOW_REQUEST_THRESHOLD_MS = 500


class TimingMiddleware(BaseHTTPMiddleware):
    """Logger requests der tager over 500ms — identificér slow queries."""
    async def dispatch(self, request: Request, call_next) -> Response:
        start = time.monotonic()
        response = await call_next(request)
        duration_ms = (time.monotonic() - start) * 1000

        response.headers["X-Response-Time"] = f"{duration_ms:.0f}ms"

        if duration_ms > SLOW_REQUEST_THRESHOLD_MS:
            log.warning(
                "request.slow",
                duration_ms=round(duration_ms, 2),
                path=request.url.path,
                method=request.method,
            )

        # OpenTelemetry metric:
        http_request_duration.record(
            duration_ms,
            {
                "method": request.method,
                "path": request.url.path,
                "status": str(response.status_code),
            }
        )

        return response

Security headers middleware

# server/src/middleware/security.py

from starlette.middleware.base import BaseHTTPMiddleware


class SecurityHeadersMiddleware(BaseHTTPMiddleware):
    """Tilføj sikkerhedsheaders til alle svar."""
    async def dispatch(self, request: Request, call_next) -> Response:
        response = await call_next(request)

        # Grundlæggende sikkerhedsheaders:
        response.headers.update({
            "X-Content-Type-Options": "nosniff",
            "X-Frame-Options": "DENY",
            "X-XSS-Protection": "1; mode=block",
            "Strict-Transport-Security": "max-age=31536000; includeSubDomains",
            "Referrer-Policy": "strict-origin-when-cross-origin",
            "Permissions-Policy": "geolocation=(), microphone=(), camera=()",
            "Content-Security-Policy": (
                "default-src 'self'; "
                "script-src 'self' 'unsafe-inline'; "    # React kræver unsafe-inline
                "style-src 'self' 'unsafe-inline'; "
                "img-src 'self' data: https:; "
                "connect-src 'self' wss: https:;"
            ),
        })

        # Fjern server-identifikation:
        response.headers.pop("Server", None)
        response.headers.pop("X-Powered-By", None)

        return response

Standardiseret error response

# server/src/middleware/error_handling.py

import json
from fastapi import Request
from fastapi.responses import JSONResponse
from fastapi.exceptions import RequestValidationError
from starlette.exceptions import HTTPException


async def validation_exception_handler(
    request: Request,
    exc: RequestValidationError,
) -> JSONResponse:
    """Konverter Pydantic-fejl til konsistent API-format."""
    errors = []
    for error in exc.errors():
        field = ".".join(str(loc) for loc in error["loc"] if loc != "body")
        errors.append({
            "field": field,
            "message": error["msg"],
            "type": error["type"],
        })

    return JSONResponse(
        status_code=422,
        content={
            "error": "validation_error",
            "message": "Request validation failed",
            "details": errors,
            "request_id": getattr(request.state, "request_id", None),
        }
    )


async def http_exception_handler(
    request: Request,
    exc: HTTPException,
) -> JSONResponse:
    """Standardisér HTTPException-svar."""
    return JSONResponse(
        status_code=exc.status_code,
        content={
            "error": _status_to_code(exc.status_code),
            "message": exc.detail,
            "request_id": getattr(request.state, "request_id", None),
        }
    )


def _status_to_code(status: int) -> str:
    return {
        400: "bad_request",
        401: "unauthorized",
        403: "forbidden",
        404: "not_found",
        409: "conflict",
        422: "validation_error",
        429: "rate_limit_exceeded",
        500: "internal_error",
    }.get(status, "error")

Registrér middleware i app

# server/src/main.py

from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.exceptions import RequestValidationError
from starlette.exceptions import HTTPException

def create_app() -> FastAPI:
    app = FastAPI(title="M-Bus Gateway API")

    # Rækkefølge er vigtig — LIFO for middleware:
    app.add_middleware(SecurityHeadersMiddleware)
    app.add_middleware(TimingMiddleware)
    app.add_middleware(TenantContextMiddleware)
    app.add_middleware(RequestLoggingMiddleware)
    app.add_middleware(
        CORSMiddleware,
        allow_origins=settings.allowed_origins,
        allow_credentials=True,
        allow_methods=["GET", "POST", "PUT", "DELETE", "PATCH"],
        allow_headers=["Authorization", "Content-Type", "X-Request-ID"],
        expose_headers=["X-Request-ID", "X-Response-Time"],
    )

    # Exception handlers:
    app.add_exception_handler(RequestValidationError, validation_exception_handler)
    app.add_exception_handler(HTTPException, http_exception_handler)

    setup_telemetry(app, engine)

    return app

Konklusion

FastAPI middleware-stack håndterer tværgående funktioner uden at forurene endpoint-logik. RequestLoggingMiddleware injicer request-ID og binder structlog-context pr. request. TenantContextMiddleware udpakker JWT til observability (authorization sker fortsat i Depends()). TimingMiddleware logger slow requests og sender metrics til OpenTelemetry. SecurityHeadersMiddleware tilføjer de nødvendige HTTP-sikkerhedsheaders. Middleware registreres LIFO — ydre lag tilføjes sidst.

Se FastAPI dependency injection guide eller structlog logging guide.