prodkit 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,123 @@
1
+ """The Production entrypoint — ProdKit's public API.
2
+
3
+ from fastapi import FastAPI
4
+ from prodkit import Production
5
+
6
+ app = FastAPI()
7
+ Production(app)
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from collections.abc import Callable
13
+ from pathlib import Path
14
+ from typing import TYPE_CHECKING, Any
15
+
16
+ from prodkit.contracts.plugin import Plugin
17
+ from prodkit.core.config import ProdKitConfig, resolve_config
18
+ from prodkit.core.context import Context
19
+ from prodkit.core.exceptions import ProdKitError
20
+ from prodkit.core.lifecycle import compose_lifespan
21
+ from prodkit.core.plugin_manager import PluginManager
22
+
23
+ if TYPE_CHECKING:
24
+ from fastapi import FastAPI
25
+
26
+ _MARKER_ATTR = "_prodkit_production"
27
+
28
+ # The built-in plugin factory is injected by prodkit/__init__.py so the kernel
29
+ # stays free of plugin imports (enforced by import-linter).
30
+ _builtin_factory: Callable[[ProdKitConfig], list[Plugin]] = lambda config: [] # noqa: E731
31
+
32
+
33
+ def set_builtin_factory(factory: Callable[[ProdKitConfig], list[Plugin]]) -> None:
34
+ global _builtin_factory
35
+ _builtin_factory = factory
36
+
37
+
38
+ # Plugin sections that can be toggled with Production(app, cors=False) etc.
39
+ _TOGGLEABLE = (
40
+ "logging",
41
+ "request_id",
42
+ "errors",
43
+ "health",
44
+ "security",
45
+ "cors",
46
+ "compression",
47
+ )
48
+
49
+
50
+ class Production:
51
+ """Configure production best practices on a FastAPI application.
52
+
53
+ Args:
54
+ app: The FastAPI application to productionize (mutated in place).
55
+ plugins: Additional plugins, activated after the built-ins.
56
+ config_file: Path to the TOML config file (default ``prodkit.toml``).
57
+ environment: ``"development"``, ``"staging"``, or ``"production"``.
58
+ **overrides: Top-level config overrides. Booleans toggle a section
59
+ (``cors=False``); dicts configure it
60
+ (``cors={"origins": ["https://app.example.com"]}``).
61
+ """
62
+
63
+ def __init__(
64
+ self,
65
+ app: FastAPI,
66
+ *,
67
+ plugins: list[Plugin] | None = None,
68
+ config_file: str | Path = "prodkit.toml",
69
+ environment: str | None = None,
70
+ **overrides: Any,
71
+ ) -> None:
72
+ if getattr(app, _MARKER_ATTR, None) is not None:
73
+ raise ProdKitError("Production() was already applied to this app")
74
+
75
+ self.config = resolve_config(
76
+ self._normalize_overrides(environment, overrides), toml_path=config_file
77
+ )
78
+ self.context = Context(app, self.config)
79
+
80
+ all_plugins = _builtin_factory(self.config) + list(plugins or [])
81
+ self.plugins = PluginManager(all_plugins).plugins
82
+
83
+ self._boot(app)
84
+ setattr(app, _MARKER_ATTR, self)
85
+
86
+ @staticmethod
87
+ def _normalize_overrides(environment: str | None, overrides: dict[str, Any]) -> dict[str, Any]:
88
+ normalized: dict[str, Any] = {}
89
+ if environment is not None:
90
+ normalized["environment"] = environment
91
+ for key, value in overrides.items():
92
+ if key in _TOGGLEABLE and isinstance(value, bool):
93
+ normalized[key] = {"enabled": value}
94
+ elif key in _TOGGLEABLE and isinstance(value, dict):
95
+ normalized[key] = {"enabled": True, **value}
96
+ else:
97
+ normalized[key] = value
98
+ return normalized
99
+
100
+ def _boot(self, app: FastAPI) -> None:
101
+ ctx = self.context
102
+ for plugin in self.plugins:
103
+ ctx._current_plugin = plugin.name
104
+ plugin.configure(ctx)
105
+ for plugin in self.plugins:
106
+ ctx._current_plugin = plugin.name
107
+ plugin.register_middleware(ctx)
108
+ ctx._current_plugin = ""
109
+
110
+ # The health plugin aggregates readiness checks from all plugins; hand
111
+ # it the activated list via its registry-published interface.
112
+ if ctx.registry.has("health"):
113
+ ctx.registry.get("health").register_plugins(self.plugins)
114
+
115
+ # Starlette applies middleware inside-out (last added = outermost),
116
+ # so apply in DESCENDING priority: highest (innermost) first.
117
+ for spec in sorted(ctx.middleware_specs(), key=lambda s: s.priority, reverse=True):
118
+ app.add_middleware(spec.cls, **spec.options) # type: ignore[arg-type]
119
+
120
+ for plugin in self.plugins:
121
+ plugin.register_routes(ctx)
122
+
123
+ app.router.lifespan_context = compose_lifespan(app, ctx, self.plugins)
@@ -0,0 +1,38 @@
1
+ """Named service registry shared across plugins."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from prodkit.core.exceptions import ProdKitError, ServiceNotFoundError
8
+
9
+
10
+ class Registry:
11
+ """Simple name → service store. Names are unique; re-registration is an
12
+ explicit error so two plugins can't silently fight over a name."""
13
+
14
+ def __init__(self) -> None:
15
+ self._services: dict[str, Any] = {}
16
+
17
+ def provide(self, name: str, service: Any) -> None:
18
+ if name in self._services:
19
+ raise ProdKitError(
20
+ f"Service {name!r} is already registered; "
21
+ "use a different name or remove the conflicting plugin"
22
+ )
23
+ self._services[name] = service
24
+
25
+ def get(self, name: str) -> Any:
26
+ try:
27
+ return self._services[name]
28
+ except KeyError:
29
+ available = ", ".join(sorted(self._services)) or "<none>"
30
+ raise ServiceNotFoundError(
31
+ f"No service named {name!r}; available: {available}"
32
+ ) from None
33
+
34
+ def has(self, name: str) -> bool:
35
+ return name in self._services
36
+
37
+ def names(self) -> list[str]:
38
+ return sorted(self._services)
@@ -0,0 +1,54 @@
1
+ """Built-in plugin composition.
2
+
3
+ This module (not the kernel) knows about the concrete built-in plugins.
4
+ ``prodkit/__init__.py`` injects :func:`builtin_plugins` into the kernel at
5
+ import time, keeping ``prodkit.core`` free of plugin imports (enforced by
6
+ import-linter).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import TYPE_CHECKING
12
+
13
+ from prodkit.plugins.compression import CompressionPlugin
14
+ from prodkit.plugins.cors import CORSPlugin
15
+ from prodkit.plugins.errors import ErrorsPlugin
16
+ from prodkit.plugins.health import HealthPlugin
17
+ from prodkit.plugins.logging import LoggingPlugin
18
+ from prodkit.plugins.request_id import RequestIDPlugin
19
+ from prodkit.plugins.security import SecurityPlugin
20
+
21
+ if TYPE_CHECKING:
22
+ from prodkit.contracts.plugin import Plugin
23
+ from prodkit.core.config import ProdKitConfig
24
+
25
+ __all__ = [
26
+ "CORSPlugin",
27
+ "CompressionPlugin",
28
+ "ErrorsPlugin",
29
+ "HealthPlugin",
30
+ "LoggingPlugin",
31
+ "RequestIDPlugin",
32
+ "SecurityPlugin",
33
+ "builtin_plugins",
34
+ ]
35
+
36
+
37
+ def builtin_plugins(config: ProdKitConfig) -> list[Plugin]:
38
+ """The built-in plugins enabled by the given configuration."""
39
+ plugins: list[Plugin] = []
40
+ if config.request_id.enabled:
41
+ plugins.append(RequestIDPlugin())
42
+ if config.logging.enabled:
43
+ plugins.append(LoggingPlugin())
44
+ if config.errors.enabled:
45
+ plugins.append(ErrorsPlugin())
46
+ if config.health.enabled:
47
+ plugins.append(HealthPlugin())
48
+ if config.security.enabled:
49
+ plugins.append(SecurityPlugin())
50
+ if config.cors.enabled:
51
+ plugins.append(CORSPlugin())
52
+ if config.compression.enabled:
53
+ plugins.append(CompressionPlugin())
54
+ return plugins
@@ -0,0 +1,22 @@
1
+ """Compression plugin: gzip via Starlette; innermost of the built-in
2
+ middleware so it compresses final response bodies."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from typing import ClassVar
7
+
8
+ from starlette.middleware.gzip import GZipMiddleware
9
+
10
+ from prodkit.contracts.plugin import PRIORITY_COMPRESSION, Plugin
11
+ from prodkit.core.context import Context
12
+
13
+
14
+ class CompressionPlugin(Plugin):
15
+ name: ClassVar[str] = "compression"
16
+
17
+ def register_middleware(self, ctx: Context) -> None:
18
+ ctx.add_middleware(
19
+ GZipMiddleware,
20
+ priority=PRIORITY_COMPRESSION,
21
+ minimum_size=ctx.config.compression.minimum_size,
22
+ )
@@ -0,0 +1,38 @@
1
+ """CORS plugin: thin, priority-ordered wrapper over Starlette's CORSMiddleware.
2
+
3
+ The dangerous combination (origins=['*'] + allow_credentials=True) is rejected
4
+ at config-resolution time in production.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import ClassVar
10
+
11
+ from starlette.middleware.cors import CORSMiddleware
12
+
13
+ from prodkit.contracts.plugin import PRIORITY_CORS, Plugin
14
+ from prodkit.core.context import Context
15
+ from prodkit.core.exceptions import ProdKitConfigError
16
+
17
+
18
+ class CORSPlugin(Plugin):
19
+ name: ClassVar[str] = "cors"
20
+
21
+ def configure(self, ctx: Context) -> None:
22
+ if not ctx.config.cors.origins:
23
+ raise ProdKitConfigError(
24
+ "cors is enabled but no origins are configured; set "
25
+ "cors={'origins': ['https://app.example.com']} or disable it"
26
+ )
27
+
28
+ def register_middleware(self, ctx: Context) -> None:
29
+ cfg = ctx.config.cors
30
+ ctx.add_middleware(
31
+ CORSMiddleware,
32
+ priority=PRIORITY_CORS,
33
+ allow_origins=cfg.origins,
34
+ allow_credentials=cfg.allow_credentials,
35
+ allow_methods=cfg.allow_methods,
36
+ allow_headers=cfg.allow_headers,
37
+ max_age=cfg.max_age,
38
+ )
@@ -0,0 +1,98 @@
1
+ """Errors plugin: normalized error responses per RFC 9457 (problem+json).
2
+
3
+ - HTTPException and validation errors keep their semantics, reshaped into a
4
+ consistent problem-details body (via FastAPI exception handlers).
5
+ - Unhandled exceptions are caught by our own middleware INSIDE the request-id
6
+ scope — Starlette's outermost ServerErrorMiddleware would run after the
7
+ request-id contextvar is reset, losing log/response correlation. The
8
+ traceback is logged with the request ID, never sent to the client (unless
9
+ include_debug_details, which the config layer refuses in production).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import logging
15
+ import traceback
16
+ from typing import Any, ClassVar
17
+
18
+ from fastapi import FastAPI, Request
19
+ from fastapi.exceptions import RequestValidationError
20
+ from starlette.exceptions import HTTPException as StarletteHTTPException
21
+ from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
22
+ from starlette.responses import JSONResponse, Response
23
+
24
+ from prodkit.contracts.plugin import PRIORITY_ERRORS, Plugin
25
+ from prodkit.core.context import Context
26
+ from prodkit.plugins.request_id import get_request_id
27
+
28
+ logger = logging.getLogger("prodkit")
29
+
30
+ _MEDIA_TYPE = "application/problem+json"
31
+
32
+
33
+ def _problem(status: int, title: str, detail: Any = None, **extra: Any) -> JSONResponse:
34
+ body: dict[str, Any] = {"type": "about:blank", "title": title, "status": status}
35
+ if detail is not None:
36
+ body["detail"] = detail
37
+ request_id = get_request_id()
38
+ if request_id:
39
+ body["request_id"] = request_id
40
+ body.update(extra)
41
+ return JSONResponse(body, status_code=status, media_type=_MEDIA_TYPE)
42
+
43
+
44
+ class ErrorHandlingMiddleware(BaseHTTPMiddleware):
45
+ """Catch-all for exceptions no handler dealt with. Runs inside request-id
46
+ and logging middleware so the 500 is correlated and access-logged."""
47
+
48
+ def __init__(self, app, include_debug_details: bool = False): # type: ignore[no-untyped-def]
49
+ super().__init__(app)
50
+ self.include_debug_details = include_debug_details
51
+
52
+ async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
53
+ try:
54
+ return await call_next(request)
55
+ except Exception as exc:
56
+ # Full traceback to logs (correlated by request ID) ...
57
+ logger.exception("Unhandled exception on %s %s", request.method, request.url.path)
58
+ if self.include_debug_details:
59
+ return _problem(
60
+ 500,
61
+ "Internal Server Error",
62
+ detail=str(exc),
63
+ traceback=traceback.format_exc().splitlines(),
64
+ )
65
+ # ... opaque response to the client. The request_id in the body
66
+ # is what support/ops use to find the logged traceback.
67
+ return _problem(500, "Internal Server Error")
68
+
69
+
70
+ def install_error_handlers(app: FastAPI) -> None:
71
+ @app.exception_handler(StarletteHTTPException)
72
+ async def http_exception_handler(
73
+ request: Request, exc: StarletteHTTPException
74
+ ) -> JSONResponse:
75
+ response = _problem(exc.status_code, exc.detail or "HTTP error")
76
+ for key, value in (exc.headers or {}).items():
77
+ response.headers[key] = value
78
+ return response
79
+
80
+ @app.exception_handler(RequestValidationError)
81
+ async def validation_exception_handler(
82
+ request: Request, exc: RequestValidationError
83
+ ) -> JSONResponse:
84
+ return _problem(422, "Validation error", detail=exc.errors())
85
+
86
+
87
+ class ErrorsPlugin(Plugin):
88
+ name: ClassVar[str] = "errors"
89
+
90
+ def register_middleware(self, ctx: Context) -> None:
91
+ ctx.add_middleware(
92
+ ErrorHandlingMiddleware,
93
+ priority=PRIORITY_ERRORS,
94
+ include_debug_details=ctx.config.errors.include_debug_details,
95
+ )
96
+
97
+ def register_routes(self, ctx: Context) -> None:
98
+ install_error_handlers(ctx.app)
@@ -0,0 +1,72 @@
1
+ """Health plugin: Kubernetes-native endpoints.
2
+
3
+ - /live → 200 if the process is alive (liveness probe)
4
+ - /health → alias for liveness, the conventional human-checked URL
5
+ - /ready → 200 only when every registered plugin check passes; 503 with
6
+ per-check detail otherwise (readiness probe)
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import inspect
12
+ from typing import Any, ClassVar
13
+
14
+ from starlette.responses import JSONResponse
15
+
16
+ from prodkit.contracts.plugin import Check, Plugin
17
+ from prodkit.core.context import Context
18
+ from prodkit.core.exceptions import ProdKitError
19
+
20
+
21
+ class HealthPlugin(Plugin):
22
+ name: ClassVar[str] = "health"
23
+
24
+ def __init__(self) -> None:
25
+ self._ctx: Context | None = None
26
+ self._plugins: list[Plugin] = []
27
+
28
+ def configure(self, ctx: Context) -> None:
29
+ self._ctx = ctx
30
+ # The health plugin aggregates checks from every active plugin; the
31
+ # registry hands us the list without coupling to the plugin manager.
32
+ ctx.registry.provide("health", self)
33
+
34
+ def register_plugins(self, plugins: list[Plugin]) -> None:
35
+ self._plugins = plugins
36
+
37
+ async def run_checks(self) -> list[Check]:
38
+ if self._ctx is None: # pragma: no cover - configure() always runs first
39
+ raise ProdKitError("HealthPlugin used before configure()")
40
+ results: list[Check] = []
41
+ for plugin in self._plugins:
42
+ checks = plugin.checks(self._ctx)
43
+ if inspect.isawaitable(checks):
44
+ checks = await checks
45
+ results.extend(checks)
46
+ return results
47
+
48
+ def register_routes(self, ctx: Context) -> None:
49
+ cfg = ctx.config.health
50
+
51
+ async def live() -> JSONResponse:
52
+ return JSONResponse({"status": "alive"})
53
+
54
+ async def health() -> JSONResponse:
55
+ return JSONResponse({"status": "ok"})
56
+
57
+ async def ready() -> JSONResponse:
58
+ checks = await self.run_checks()
59
+ failed = [c for c in checks if not c.passed]
60
+ payload: dict[str, Any] = {
61
+ "status": "ready" if not failed else "not ready",
62
+ "checks": [
63
+ {"name": c.name, "passed": c.passed, "detail": c.detail} for c in checks
64
+ ],
65
+ }
66
+ return JSONResponse(payload, status_code=200 if not failed else 503)
67
+
68
+ # Excluded from the OpenAPI schema: probe endpoints are infrastructure,
69
+ # not API surface.
70
+ ctx.app.add_api_route(cfg.live_path, live, include_in_schema=False)
71
+ ctx.app.add_api_route(cfg.health_path, health, include_in_schema=False)
72
+ ctx.app.add_api_route(cfg.ready_path, ready, include_in_schema=False)
@@ -0,0 +1,116 @@
1
+ """Structured logging plugin.
2
+
3
+ JSON logs in production (machine-parseable), pretty console logs in
4
+ development. Every request gets one access-log line with method, path,
5
+ status, duration, and the correlated request ID.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import logging
12
+ import sys
13
+ import time
14
+ from typing import Any, ClassVar
15
+
16
+ from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
17
+ from starlette.requests import Request
18
+ from starlette.responses import Response
19
+
20
+ from prodkit.contracts.plugin import PRIORITY_LOGGING, Plugin
21
+ from prodkit.core.context import Context
22
+ from prodkit.plugins.request_id import get_request_id
23
+
24
+ _CONTROL_CHARS = dict.fromkeys(range(32))
25
+
26
+
27
+ def _sanitize(value: str) -> str:
28
+ """Strip control characters (CR/LF included) so attacker-controlled
29
+ strings (paths, headers) cannot forge extra log lines."""
30
+ return value.translate(_CONTROL_CHARS)
31
+
32
+
33
+ class JSONFormatter(logging.Formatter):
34
+ def format(self, record: logging.LogRecord) -> str:
35
+ entry: dict[str, Any] = {
36
+ "timestamp": self.formatTime(record, "%Y-%m-%dT%H:%M:%S%z"),
37
+ "level": record.levelname,
38
+ "logger": record.name,
39
+ "message": record.getMessage(),
40
+ }
41
+ request_id = get_request_id()
42
+ if request_id:
43
+ entry["request_id"] = request_id
44
+ if record.exc_info and record.exc_info[0] is not None:
45
+ entry["exception"] = self.formatException(record.exc_info)
46
+ extra = getattr(record, "prodkit_extra", None)
47
+ if isinstance(extra, dict):
48
+ entry.update(extra)
49
+ return json.dumps(entry, default=str)
50
+
51
+
52
+ class ConsoleFormatter(logging.Formatter):
53
+ def format(self, record: logging.LogRecord) -> str:
54
+ request_id = get_request_id()
55
+ rid = f" [{request_id[:8]}]" if request_id else ""
56
+ base = (
57
+ f"{self.formatTime(record, '%H:%M:%S')} "
58
+ f"{record.levelname:<8}{rid} {record.getMessage()}"
59
+ )
60
+ if record.exc_info and record.exc_info[0] is not None:
61
+ base += "\n" + self.formatException(record.exc_info)
62
+ return base
63
+
64
+
65
+ class AccessLogMiddleware(BaseHTTPMiddleware):
66
+ def __init__(self, app, logger_name: str = "prodkit.access"): # type: ignore[no-untyped-def]
67
+ super().__init__(app)
68
+ self.logger = logging.getLogger(logger_name)
69
+
70
+ async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
71
+ start = time.perf_counter()
72
+ response = await call_next(request)
73
+ duration_ms = (time.perf_counter() - start) * 1000
74
+ path = _sanitize(request.url.path)
75
+ self.logger.info(
76
+ "%s %s %d %.1fms",
77
+ request.method,
78
+ path,
79
+ response.status_code,
80
+ duration_ms,
81
+ extra={
82
+ "prodkit_extra": {
83
+ "method": request.method,
84
+ "path": path,
85
+ "status": response.status_code,
86
+ "duration_ms": round(duration_ms, 1),
87
+ }
88
+ },
89
+ )
90
+ return response
91
+
92
+
93
+ def configure_logging(level: str, log_format: str) -> None:
94
+ formatter: logging.Formatter
95
+ formatter = JSONFormatter() if log_format == "json" else ConsoleFormatter()
96
+ handler = logging.StreamHandler(sys.stdout)
97
+ handler.setFormatter(formatter)
98
+
99
+ for name in ("prodkit", "prodkit.access"):
100
+ logger = logging.getLogger(name)
101
+ logger.handlers = [handler]
102
+ logger.setLevel(level.upper())
103
+ logger.propagate = False
104
+
105
+
106
+ class LoggingPlugin(Plugin):
107
+ # No hard dependency on request-id: correlation degrades gracefully to ""
108
+ # when that plugin is disabled, and middleware order comes from priorities.
109
+ name: ClassVar[str] = "logging"
110
+
111
+ def configure(self, ctx: Context) -> None:
112
+ cfg = ctx.config.logging
113
+ configure_logging(cfg.level, cfg.format)
114
+
115
+ def register_middleware(self, ctx: Context) -> None:
116
+ ctx.add_middleware(AccessLogMiddleware, priority=PRIORITY_LOGGING)
@@ -0,0 +1,61 @@
1
+ """Request-ID plugin: generates/propagates X-Request-ID and exposes it via a
2
+ contextvar so the logging plugin can correlate every log line to a request."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import re
7
+ import uuid
8
+ from contextvars import ContextVar
9
+ from typing import ClassVar
10
+
11
+ from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
12
+ from starlette.requests import Request
13
+ from starlette.responses import Response
14
+
15
+ from prodkit.contracts.plugin import PRIORITY_REQUEST_ID, Plugin
16
+ from prodkit.core.context import Context
17
+
18
+ request_id_var: ContextVar[str] = ContextVar("prodkit_request_id", default="")
19
+
20
+ # Inbound IDs are attacker-controlled input headed for logs and response
21
+ # headers: constrain to a safe charset and length to prevent log injection.
22
+ _SAFE_ID = re.compile(r"^[A-Za-z0-9._-]{1,128}$")
23
+
24
+
25
+ def get_request_id() -> str:
26
+ """The current request's ID, or '' outside a request."""
27
+ return request_id_var.get()
28
+
29
+
30
+ class RequestIDMiddleware(BaseHTTPMiddleware):
31
+ def __init__(self, app, header: str = "X-Request-ID", trust_incoming: bool = False): # type: ignore[no-untyped-def]
32
+ super().__init__(app)
33
+ self.header = header
34
+ self.trust_incoming = trust_incoming
35
+
36
+ async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
37
+ incoming = request.headers.get(self.header, "")
38
+ if self.trust_incoming and incoming and _SAFE_ID.match(incoming):
39
+ request_id = incoming
40
+ else:
41
+ request_id = uuid.uuid4().hex
42
+ token = request_id_var.set(request_id)
43
+ try:
44
+ response = await call_next(request)
45
+ finally:
46
+ request_id_var.reset(token)
47
+ response.headers[self.header] = request_id
48
+ return response
49
+
50
+
51
+ class RequestIDPlugin(Plugin):
52
+ name: ClassVar[str] = "request-id"
53
+
54
+ def register_middleware(self, ctx: Context) -> None:
55
+ cfg = ctx.config.request_id
56
+ ctx.add_middleware(
57
+ RequestIDMiddleware,
58
+ priority=PRIORITY_REQUEST_ID,
59
+ header=cfg.header,
60
+ trust_incoming=cfg.trust_incoming,
61
+ )