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.
- prodkit/__init__.py +57 -0
- prodkit/contracts/__init__.py +0 -0
- prodkit/contracts/plugin.py +61 -0
- prodkit/core/__init__.py +0 -0
- prodkit/core/config.py +245 -0
- prodkit/core/context.py +53 -0
- prodkit/core/event_bus.py +42 -0
- prodkit/core/exceptions.py +23 -0
- prodkit/core/lifecycle.py +54 -0
- prodkit/core/plugin_manager.py +52 -0
- prodkit/core/production.py +123 -0
- prodkit/core/registry.py +38 -0
- prodkit/plugins/__init__.py +54 -0
- prodkit/plugins/compression/__init__.py +22 -0
- prodkit/plugins/cors/__init__.py +38 -0
- prodkit/plugins/errors/__init__.py +98 -0
- prodkit/plugins/health/__init__.py +72 -0
- prodkit/plugins/logging/__init__.py +116 -0
- prodkit/plugins/request_id/__init__.py +61 -0
- prodkit/plugins/security/__init__.py +78 -0
- prodkit/py.typed +0 -0
- prodkit-0.1.0.dist-info/METADATA +264 -0
- prodkit-0.1.0.dist-info/RECORD +25 -0
- prodkit-0.1.0.dist-info/WHEEL +4 -0
- prodkit-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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)
|
prodkit/core/registry.py
ADDED
|
@@ -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
|
+
)
|