simple-module-hosting 0.0.1__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.
- simple_module_hosting/__init__.py +7 -0
- simple_module_hosting/_error_handlers.py +54 -0
- simple_module_hosting/_hydrate_step.py +39 -0
- simple_module_hosting/_inertia_setup.py +73 -0
- simple_module_hosting/_inertia_shared.py +61 -0
- simple_module_hosting/_observability.py +108 -0
- simple_module_hosting/_phase_helpers.py +160 -0
- simple_module_hosting/app_builder.py +281 -0
- simple_module_hosting/bootstrap_settings.py +55 -0
- simple_module_hosting/cli.py +292 -0
- simple_module_hosting/health.py +79 -0
- simple_module_hosting/host_settings.py +33 -0
- simple_module_hosting/i18n_deps.py +25 -0
- simple_module_hosting/i18n_manifest.py +202 -0
- simple_module_hosting/i18n_middleware.py +95 -0
- simple_module_hosting/inertia_deps.py +27 -0
- simple_module_hosting/inertia_utils.py +31 -0
- simple_module_hosting/logging.py +91 -0
- simple_module_hosting/manifest.py +250 -0
- simple_module_hosting/middleware.py +272 -0
- simple_module_hosting/migrations.py +65 -0
- simple_module_hosting/permissions.py +75 -0
- simple_module_hosting/py.typed +0 -0
- simple_module_hosting/redirects.py +45 -0
- simple_module_hosting/scaffolding.py +294 -0
- simple_module_hosting/settings.py +10 -0
- simple_module_hosting/templates/host/.env.example +20 -0
- simple_module_hosting/templates/host/.gitignore +19 -0
- simple_module_hosting/templates/host/Makefile +24 -0
- simple_module_hosting/templates/host/README.md.tpl +59 -0
- simple_module_hosting/templates/host/alembic.ini +36 -0
- simple_module_hosting/templates/host/client_app/app.tsx +16 -0
- simple_module_hosting/templates/host/client_app/main.tsx +2 -0
- simple_module_hosting/templates/host/client_app/package.json.tpl +23 -0
- simple_module_hosting/templates/host/client_app/pages/Error.tsx +13 -0
- simple_module_hosting/templates/host/client_app/pages.ts +47 -0
- simple_module_hosting/templates/host/client_app/styles.css +7 -0
- simple_module_hosting/templates/host/client_app/tsconfig.json +16 -0
- simple_module_hosting/templates/host/client_app/vite.config.ts +39 -0
- simple_module_hosting/templates/host/main.py +27 -0
- simple_module_hosting/templates/host/migrations/env.py +80 -0
- simple_module_hosting/templates/host/migrations/script.py.mako +26 -0
- simple_module_hosting/templates/host/migrations/versions/.gitkeep +1 -0
- simple_module_hosting/templates/host/pyproject.toml.tpl +17 -0
- simple_module_hosting/templates/host/templates/index.html +12 -0
- simple_module_hosting/templates/module/.github/workflows/ci.yml +32 -0
- simple_module_hosting/templates/module/.github/workflows/publish.yml.tpl +52 -0
- simple_module_hosting/templates/module/.gitignore +14 -0
- simple_module_hosting/templates/module/README.md.tpl +82 -0
- simple_module_hosting/templates/module/__PACKAGE__/__init__.py +0 -0
- simple_module_hosting/templates/module/__PACKAGE__/endpoints/__init__.py +0 -0
- simple_module_hosting/templates/module/__PACKAGE__/endpoints/api.py.tpl +11 -0
- simple_module_hosting/templates/module/__PACKAGE__/module.py.tpl +46 -0
- simple_module_hosting/templates/module/__PACKAGE__/pages/.gitkeep +1 -0
- simple_module_hosting/templates/module/__PACKAGE__/services.py.tpl +22 -0
- simple_module_hosting/templates/module/package.json.tpl +16 -0
- simple_module_hosting/templates/module/pyproject.toml.tpl +39 -0
- simple_module_hosting/templates/module/tests/__init__.py +0 -0
- simple_module_hosting/templates/module/tests/test_module.py.tpl +27 -0
- simple_module_hosting/templates/module/tsconfig.json.tpl +11 -0
- simple_module_hosting-0.0.1.dist-info/METADATA +93 -0
- simple_module_hosting-0.0.1.dist-info/RECORD +65 -0
- simple_module_hosting-0.0.1.dist-info/WHEEL +4 -0
- simple_module_hosting-0.0.1.dist-info/entry_points.txt +3 -0
- simple_module_hosting-0.0.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""SimpleModule Hosting - App builder, module loader, middleware pipeline."""
|
|
2
|
+
|
|
3
|
+
from simple_module_hosting.app_builder import create_app
|
|
4
|
+
from simple_module_hosting.logging import correlation_id, setup_logging
|
|
5
|
+
from simple_module_hosting.settings import Settings
|
|
6
|
+
|
|
7
|
+
__all__ = ["Settings", "correlation_id", "create_app", "setup_logging"]
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Framework-wide exception handlers that render Inertia error pages."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
|
|
7
|
+
from fastapi.responses import JSONResponse
|
|
8
|
+
from inertia import (
|
|
9
|
+
Inertia,
|
|
10
|
+
InertiaConfig,
|
|
11
|
+
InertiaVersionConflictException,
|
|
12
|
+
inertia_version_conflict_exception_handler,
|
|
13
|
+
)
|
|
14
|
+
from simple_module_core.exceptions import NotFoundError
|
|
15
|
+
from starlette.exceptions import HTTPException
|
|
16
|
+
from starlette.requests import Request
|
|
17
|
+
from starlette.responses import Response
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
_INERTIA_ERROR_STATUSES = frozenset({403, 404, 500})
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
async def render_error_page(request: Request, status_code: int, message: str) -> Response:
|
|
25
|
+
config: InertiaConfig = request.app.state.sm.inertia_config
|
|
26
|
+
try:
|
|
27
|
+
inertia = Inertia(request, config)
|
|
28
|
+
response = await inertia.render("Error", {"status": status_code, "message": message})
|
|
29
|
+
response.status_code = status_code
|
|
30
|
+
return response
|
|
31
|
+
except InertiaVersionConflictException as exc:
|
|
32
|
+
return await inertia_version_conflict_exception_handler(request, exc)
|
|
33
|
+
except Exception:
|
|
34
|
+
# Fallback if Inertia rendering itself fails (e.g. missing session)
|
|
35
|
+
logger.exception("Error page rendering failed, falling back to JSON")
|
|
36
|
+
return JSONResponse(
|
|
37
|
+
status_code=status_code, content={"detail": message or "Internal Server Error"}
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
async def http_exception_handler(request: Request, exc: HTTPException) -> Response:
|
|
42
|
+
if exc.status_code in _INERTIA_ERROR_STATUSES:
|
|
43
|
+
detail = str(exc.detail) if exc.detail else ""
|
|
44
|
+
return await render_error_page(request, exc.status_code, detail)
|
|
45
|
+
return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail})
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
async def not_found_error_handler(request: Request, exc: NotFoundError) -> Response:
|
|
49
|
+
return await render_error_page(request, 404, str(exc))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
async def unhandled_exception_handler(request: Request, exc: Exception) -> Response:
|
|
53
|
+
logger.exception("Unhandled exception: %s", exc)
|
|
54
|
+
return await render_error_page(request, 500, "")
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Hydrate every registered module's settings from the DB at lifespan start.
|
|
2
|
+
|
|
3
|
+
Runs before any module ``on_startup`` hook so startup code sees DB-backed
|
|
4
|
+
values. ``importlib`` is used to resolve plugin names lazily so the
|
|
5
|
+
framework→plugin AST check (SM009) stays clean.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import importlib
|
|
11
|
+
import logging
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from fastapi import FastAPI
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
_MODULE_PACKAGE = "settings"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
async def hydrate_all(app: FastAPI, store: Any) -> None:
|
|
22
|
+
"""Resolve every registered module's settings from the DB."""
|
|
23
|
+
settings_services = getattr(app.state, _MODULE_PACKAGE, None)
|
|
24
|
+
if settings_services is None:
|
|
25
|
+
return
|
|
26
|
+
|
|
27
|
+
hydrate_settings = importlib.import_module("settings.hydrate").hydrate_settings
|
|
28
|
+
|
|
29
|
+
for package, cls in settings_services.module_registry.items():
|
|
30
|
+
try:
|
|
31
|
+
hydrated = await hydrate_settings(cls, store, package)
|
|
32
|
+
except Exception:
|
|
33
|
+
logger.exception("Hydrating %s failed; falling back to defaults", package)
|
|
34
|
+
continue
|
|
35
|
+
services = getattr(app.state, package, None)
|
|
36
|
+
if services is None:
|
|
37
|
+
logger.warning("app.state.%s missing during hydrate — skipping", package)
|
|
38
|
+
continue
|
|
39
|
+
services.settings = hydrated
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Configure fastapi-inertia with the Jinja2 template."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from fastapi import FastAPI
|
|
9
|
+
from inertia import InertiaConfig, inertia_dependency_factory
|
|
10
|
+
|
|
11
|
+
from simple_module_hosting.settings import Settings
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
_INERTIA_VERSION = "1.0"
|
|
16
|
+
_ROOT_TEMPLATE_FILENAME = "index.html"
|
|
17
|
+
_ENTRYPOINT_FILENAME = "main.tsx"
|
|
18
|
+
_ROOT_DIRECTORY = "."
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def setup_inertia(
|
|
22
|
+
app: FastAPI,
|
|
23
|
+
settings: Settings,
|
|
24
|
+
modules: list,
|
|
25
|
+
project_root: Path,
|
|
26
|
+
) -> InertiaConfig | None:
|
|
27
|
+
"""Configure fastapi-inertia and attach the dependency factory to app.state.
|
|
28
|
+
|
|
29
|
+
The host's own ``host/templates`` directory is first in the search path so
|
|
30
|
+
it can override module-contributed templates. Each installed module
|
|
31
|
+
contributes additional directories via ``ModuleBase.template_dirs()``.
|
|
32
|
+
"""
|
|
33
|
+
from fastapi.templating import Jinja2Templates
|
|
34
|
+
|
|
35
|
+
host_templates = project_root / "host" / "templates"
|
|
36
|
+
directories: list[Path] = []
|
|
37
|
+
|
|
38
|
+
if host_templates.is_dir():
|
|
39
|
+
directories.append(host_templates)
|
|
40
|
+
else:
|
|
41
|
+
logger.warning("Host templates directory not found at %s", host_templates)
|
|
42
|
+
|
|
43
|
+
for mod in modules:
|
|
44
|
+
for path in mod.template_dirs():
|
|
45
|
+
if Path(path).is_dir():
|
|
46
|
+
directories.append(Path(path))
|
|
47
|
+
else:
|
|
48
|
+
logger.warning(
|
|
49
|
+
"Module '%s' declared template dir %s but it does not exist",
|
|
50
|
+
mod.meta.name,
|
|
51
|
+
path,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
if not directories:
|
|
55
|
+
logger.warning("No usable template directories — Inertia will fail to render views")
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
templates = Jinja2Templates(directory=directories)
|
|
59
|
+
|
|
60
|
+
inertia_config = InertiaConfig(
|
|
61
|
+
environment=settings.environment,
|
|
62
|
+
version=_INERTIA_VERSION,
|
|
63
|
+
dev_url=settings.vite_dev_url if settings.is_development else "",
|
|
64
|
+
templates=templates,
|
|
65
|
+
root_template_filename=_ROOT_TEMPLATE_FILENAME,
|
|
66
|
+
entrypoint_filename=_ENTRYPOINT_FILENAME,
|
|
67
|
+
root_directory=_ROOT_DIRECTORY,
|
|
68
|
+
use_flash_errors=True,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
inertia_dep = inertia_dependency_factory(inertia_config)
|
|
72
|
+
app.state.inertia_dependency = inertia_dep
|
|
73
|
+
return inertia_config
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Helpers for building Inertia shared-props payloads."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
|
|
7
|
+
from starlette.datastructures import Headers
|
|
8
|
+
from starlette.requests import Request
|
|
9
|
+
from starlette.types import Scope
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
_I18N_SESSION_LOCALE_KEY = "__i18n_locale"
|
|
14
|
+
_INERTIA_HEADER = "x-inertia"
|
|
15
|
+
_INERTIA_HEADER_TRUE = "true"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def build_i18n_block(scope: Scope, request: Request) -> dict:
|
|
19
|
+
"""Assemble the ``i18n`` shared-props block for the current request.
|
|
20
|
+
|
|
21
|
+
Rules:
|
|
22
|
+
|
|
23
|
+
* No registry / no locale → serve an empty English block and log once.
|
|
24
|
+
* Inertia XHR partials (``X-Inertia: true``) reuse the client-side
|
|
25
|
+
cached messages; send ``messages: None`` unless the locale differs
|
|
26
|
+
from what was last served on this session.
|
|
27
|
+
* Full page loads and locale transitions ship the complete dict.
|
|
28
|
+
"""
|
|
29
|
+
# Test fixtures sometimes build a bare FastAPI with a partial app.state.sm
|
|
30
|
+
# stub (e.g. permissions-only, no i18n); guard both lookups to keep them usable.
|
|
31
|
+
sm = getattr(request.app.state, "sm", None)
|
|
32
|
+
registry = getattr(sm, "i18n_registry", None) if sm is not None else None
|
|
33
|
+
locale = getattr(request.state, "locale", None)
|
|
34
|
+
if registry is None or locale is None:
|
|
35
|
+
logger.warning(
|
|
36
|
+
"InertiaLayoutDataMiddleware: i18n not fully wired "
|
|
37
|
+
"(registry_present=%s, locale_present=%s); serving empty messages",
|
|
38
|
+
registry is not None,
|
|
39
|
+
locale is not None,
|
|
40
|
+
)
|
|
41
|
+
return {"locale": "en", "supportedLocales": ["en"], "messages": {}}
|
|
42
|
+
|
|
43
|
+
is_inertia = Headers(scope=scope).get(_INERTIA_HEADER) == _INERTIA_HEADER_TRUE
|
|
44
|
+
session_dict = scope.get("session")
|
|
45
|
+
# When the session is absent (pre-session-middleware routes, WebSocket
|
|
46
|
+
# upgrades), treat locale as "unchanged" so Inertia XHR requests still
|
|
47
|
+
# skip the messages payload. Non-Inertia requests will always ship them
|
|
48
|
+
# regardless of the session state.
|
|
49
|
+
if session_dict is not None:
|
|
50
|
+
last_locale = session_dict.get(_I18N_SESSION_LOCALE_KEY)
|
|
51
|
+
locale_changed = last_locale != locale
|
|
52
|
+
if locale_changed:
|
|
53
|
+
session_dict[_I18N_SESSION_LOCALE_KEY] = locale
|
|
54
|
+
else:
|
|
55
|
+
locale_changed = False
|
|
56
|
+
send_messages = (not is_inertia) or locale_changed
|
|
57
|
+
return {
|
|
58
|
+
"locale": locale,
|
|
59
|
+
"supportedLocales": registry.available_locales(),
|
|
60
|
+
"messages": registry.messages_snapshot(locale) if send_messages else None,
|
|
61
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""ASGI middlewares for correlation IDs and structured request logging."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import time
|
|
7
|
+
import uuid
|
|
8
|
+
|
|
9
|
+
from starlette.datastructures import Headers, MutableHeaders
|
|
10
|
+
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
|
11
|
+
|
|
12
|
+
from simple_module_hosting.logging import correlation_id
|
|
13
|
+
|
|
14
|
+
_LOGGER_NAME = "simple_module.request"
|
|
15
|
+
_request_logger = logging.getLogger(_LOGGER_NAME)
|
|
16
|
+
|
|
17
|
+
_SCOPE_HTTP = "http"
|
|
18
|
+
_MSG_RESPONSE_START = "http.response.start"
|
|
19
|
+
_EVENT_REQUEST_STARTED = "request.started"
|
|
20
|
+
_EVENT_REQUEST_COMPLETED = "request.completed"
|
|
21
|
+
|
|
22
|
+
# Paths that produce noisy, low-value log entries
|
|
23
|
+
_QUIET_PREFIXES = ("/health", "/static/")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class CorrelationIdMiddleware:
|
|
27
|
+
"""Generate or propagate a correlation ID for every request.
|
|
28
|
+
|
|
29
|
+
Reads the incoming ``X-Correlation-ID`` header (or generates a UUID4) and
|
|
30
|
+
stores it in a :class:`~contextvars.ContextVar` so that every log record
|
|
31
|
+
emitted during the request automatically includes the ID. The same value
|
|
32
|
+
is echoed back in the response header.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
HEADER = "X-Correlation-ID"
|
|
36
|
+
|
|
37
|
+
def __init__(self, app: ASGIApp) -> None:
|
|
38
|
+
self.app = app
|
|
39
|
+
|
|
40
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
41
|
+
if scope["type"] != _SCOPE_HTTP:
|
|
42
|
+
await self.app(scope, receive, send)
|
|
43
|
+
return
|
|
44
|
+
|
|
45
|
+
cid = Headers(scope=scope).get(self.HEADER) or uuid.uuid4().hex
|
|
46
|
+
|
|
47
|
+
async def send_with_header(message: Message) -> None:
|
|
48
|
+
if message["type"] == _MSG_RESPONSE_START:
|
|
49
|
+
headers = MutableHeaders(scope=message)
|
|
50
|
+
headers[self.HEADER] = cid
|
|
51
|
+
await send(message)
|
|
52
|
+
|
|
53
|
+
token = correlation_id.set(cid)
|
|
54
|
+
try:
|
|
55
|
+
await self.app(scope, receive, send_with_header)
|
|
56
|
+
finally:
|
|
57
|
+
correlation_id.reset(token)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class RequestLoggingMiddleware:
|
|
61
|
+
"""Log every request/response pair with timing and status information."""
|
|
62
|
+
|
|
63
|
+
def __init__(self, app: ASGIApp) -> None:
|
|
64
|
+
self.app = app
|
|
65
|
+
|
|
66
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
67
|
+
if scope["type"] != _SCOPE_HTTP:
|
|
68
|
+
await self.app(scope, receive, send)
|
|
69
|
+
return
|
|
70
|
+
|
|
71
|
+
path = scope["path"]
|
|
72
|
+
if any(path.startswith(p) for p in _QUIET_PREFIXES):
|
|
73
|
+
await self.app(scope, receive, send)
|
|
74
|
+
return
|
|
75
|
+
|
|
76
|
+
method = scope["method"]
|
|
77
|
+
client = scope.get("client")
|
|
78
|
+
client_ip = client[0] if client else "unknown"
|
|
79
|
+
|
|
80
|
+
_request_logger.debug(
|
|
81
|
+
_EVENT_REQUEST_STARTED,
|
|
82
|
+
extra={"method": method, "path": path, "client_ip": client_ip},
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
status_code: int | None = None
|
|
86
|
+
start = time.perf_counter()
|
|
87
|
+
|
|
88
|
+
async def send_capture(message: Message) -> None:
|
|
89
|
+
nonlocal status_code
|
|
90
|
+
if message["type"] == _MSG_RESPONSE_START:
|
|
91
|
+
status_code = message["status"]
|
|
92
|
+
await send(message)
|
|
93
|
+
|
|
94
|
+
try:
|
|
95
|
+
await self.app(scope, receive, send_capture)
|
|
96
|
+
finally:
|
|
97
|
+
# Log completion even when the inner app raises, so 500s are observable.
|
|
98
|
+
duration_ms = round((time.perf_counter() - start) * 1000, 2)
|
|
99
|
+
_request_logger.info(
|
|
100
|
+
_EVENT_REQUEST_COMPLETED,
|
|
101
|
+
extra={
|
|
102
|
+
"method": method,
|
|
103
|
+
"path": path,
|
|
104
|
+
"status_code": status_code,
|
|
105
|
+
"duration_ms": duration_ms,
|
|
106
|
+
"client_ip": client_ip,
|
|
107
|
+
},
|
|
108
|
+
)
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""Helpers extracted from ``app_builder.py`` — exception handlers, middleware
|
|
2
|
+
pipeline installation, static mounts, and the SM012 post-registration check.
|
|
3
|
+
|
|
4
|
+
Kept private to the hosting package; ``app_builder.create_app`` is the only
|
|
5
|
+
intended caller.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import TYPE_CHECKING
|
|
13
|
+
|
|
14
|
+
from fastapi import FastAPI
|
|
15
|
+
from fastapi.staticfiles import StaticFiles
|
|
16
|
+
from inertia import (
|
|
17
|
+
InertiaVersionConflictException,
|
|
18
|
+
inertia_version_conflict_exception_handler,
|
|
19
|
+
)
|
|
20
|
+
from simple_module_core.diagnostics import Diagnostic, DiagnosticLevel
|
|
21
|
+
from simple_module_core.exceptions import NotFoundError
|
|
22
|
+
from starlette.exceptions import HTTPException
|
|
23
|
+
from starlette.middleware.sessions import SessionMiddleware
|
|
24
|
+
|
|
25
|
+
from simple_module_hosting._error_handlers import (
|
|
26
|
+
http_exception_handler,
|
|
27
|
+
not_found_error_handler,
|
|
28
|
+
unhandled_exception_handler,
|
|
29
|
+
)
|
|
30
|
+
from simple_module_hosting.i18n_middleware import LocaleMiddleware
|
|
31
|
+
from simple_module_hosting.middleware import (
|
|
32
|
+
CorrelationIdMiddleware,
|
|
33
|
+
InertiaLayoutDataMiddleware,
|
|
34
|
+
RequestLoggingMiddleware,
|
|
35
|
+
SecurityHeadersMiddleware,
|
|
36
|
+
TenantMiddleware,
|
|
37
|
+
)
|
|
38
|
+
from simple_module_hosting.settings import Settings
|
|
39
|
+
|
|
40
|
+
if TYPE_CHECKING:
|
|
41
|
+
from simple_module_core.menu import MenuRegistry
|
|
42
|
+
from simple_module_core.permissions import PermissionRegistry
|
|
43
|
+
|
|
44
|
+
logger = logging.getLogger(__name__)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def register_exception_handlers(app: FastAPI, modules: list) -> None:
|
|
48
|
+
"""Install framework-level exception handlers, then per-module handlers."""
|
|
49
|
+
app.add_exception_handler(
|
|
50
|
+
InertiaVersionConflictException,
|
|
51
|
+
inertia_version_conflict_exception_handler,
|
|
52
|
+
)
|
|
53
|
+
app.add_exception_handler(HTTPException, http_exception_handler)
|
|
54
|
+
app.add_exception_handler(NotFoundError, not_found_error_handler)
|
|
55
|
+
app.add_exception_handler(Exception, unhandled_exception_handler)
|
|
56
|
+
for mod in modules:
|
|
57
|
+
mod.register_exception_handlers(app)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def install_middleware(
|
|
61
|
+
app: FastAPI,
|
|
62
|
+
settings: Settings,
|
|
63
|
+
modules: list,
|
|
64
|
+
menu_registry: MenuRegistry,
|
|
65
|
+
perm_registry: PermissionRegistry,
|
|
66
|
+
) -> None:
|
|
67
|
+
"""Install the full middleware pipeline.
|
|
68
|
+
|
|
69
|
+
Order matters: last added = first executed. Execution order:
|
|
70
|
+
CorrelationId → RequestLogging → Security → Session
|
|
71
|
+
→ [module] → (Tenant, if multi_tenant) → Locale → Inertia.
|
|
72
|
+
"""
|
|
73
|
+
app.add_middleware(
|
|
74
|
+
InertiaLayoutDataMiddleware,
|
|
75
|
+
menu_registry=menu_registry,
|
|
76
|
+
permission_registry=perm_registry,
|
|
77
|
+
)
|
|
78
|
+
app.add_middleware(
|
|
79
|
+
LocaleMiddleware,
|
|
80
|
+
supported_locales=settings.i18n_supported_locales,
|
|
81
|
+
default_locale=settings.i18n_default_locale,
|
|
82
|
+
cookie_name=settings.i18n_cookie_name,
|
|
83
|
+
)
|
|
84
|
+
if settings.multi_tenant:
|
|
85
|
+
app.add_middleware(TenantMiddleware, header=settings.tenant_header or None)
|
|
86
|
+
for mod in modules:
|
|
87
|
+
mod.register_middleware(app)
|
|
88
|
+
app.add_middleware(SessionMiddleware, secret_key=settings.secret_key)
|
|
89
|
+
# In dev, relax CSP so the browser can fetch @vite/client, main.tsx, and
|
|
90
|
+
# the HMR WebSocket from the Vite origin. HSTS is also suppressed because
|
|
91
|
+
# dev runs over plain HTTP on loopback.
|
|
92
|
+
if settings.is_development:
|
|
93
|
+
app.add_middleware(
|
|
94
|
+
SecurityHeadersMiddleware,
|
|
95
|
+
content_security_policy=SecurityHeadersMiddleware.dev_csp(settings.vite_dev_url),
|
|
96
|
+
strict_transport_security=None,
|
|
97
|
+
)
|
|
98
|
+
else:
|
|
99
|
+
app.add_middleware(SecurityHeadersMiddleware)
|
|
100
|
+
app.add_middleware(RequestLoggingMiddleware)
|
|
101
|
+
app.add_middleware(CorrelationIdMiddleware)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def mount_module_static_dirs(app: FastAPI, modules: list) -> None:
|
|
105
|
+
"""Mount each module's declared static directories.
|
|
106
|
+
|
|
107
|
+
Modules typically expose ``/modules/<name>/static`` for pre-bundled
|
|
108
|
+
frontend assets shipped inside the wheel.
|
|
109
|
+
"""
|
|
110
|
+
for mod in modules:
|
|
111
|
+
for url_prefix, directory in mod.static_mounts().items():
|
|
112
|
+
directory_path = Path(directory)
|
|
113
|
+
if not directory_path.is_dir():
|
|
114
|
+
logger.warning(
|
|
115
|
+
"Module '%s' declared static mount %s -> %s but directory does not exist",
|
|
116
|
+
mod.meta.name,
|
|
117
|
+
url_prefix,
|
|
118
|
+
directory_path,
|
|
119
|
+
)
|
|
120
|
+
continue
|
|
121
|
+
app.mount(
|
|
122
|
+
url_prefix,
|
|
123
|
+
StaticFiles(directory=directory_path),
|
|
124
|
+
name=f"static:{mod.meta.name}",
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def check_settings_registration(app: FastAPI, modules: list) -> list[Diagnostic]:
|
|
129
|
+
"""SM012: warn if a module overrides register_settings but added nothing to app.state.
|
|
130
|
+
|
|
131
|
+
Must run after Phase 4 (register_settings) and therefore can't join the
|
|
132
|
+
Phase 2 diagnostics pass; returning a list lets the caller route it through
|
|
133
|
+
the same ``print_diagnostics`` sink.
|
|
134
|
+
"""
|
|
135
|
+
diagnostics: list[Diagnostic] = []
|
|
136
|
+
for mod in modules:
|
|
137
|
+
cls = type(mod)
|
|
138
|
+
if "register_settings" not in cls.__dict__:
|
|
139
|
+
continue
|
|
140
|
+
# Match the convention actually used by modules: `app.state.<package>`
|
|
141
|
+
# (snake_case package name, e.g. `background_tasks`), which aligns
|
|
142
|
+
# with Settings-module autodiscovery in `settings._module_settings`.
|
|
143
|
+
package = cls.__module__.split(".", 1)[0]
|
|
144
|
+
candidates = (package, mod.meta.name.lower())
|
|
145
|
+
if any(hasattr(app.state, c) for c in candidates):
|
|
146
|
+
continue
|
|
147
|
+
mod_prefix = package
|
|
148
|
+
diagnostics.append(
|
|
149
|
+
Diagnostic(
|
|
150
|
+
level=DiagnosticLevel.WARNING,
|
|
151
|
+
code="SM012",
|
|
152
|
+
message="register_settings() was overridden but added nothing to app.state",
|
|
153
|
+
module_name=mod.meta.name,
|
|
154
|
+
suggestion=(
|
|
155
|
+
f"Store your module state on app.state "
|
|
156
|
+
f"(e.g., app.state.{mod_prefix} = {mod.meta.name}Services(...))"
|
|
157
|
+
),
|
|
158
|
+
)
|
|
159
|
+
)
|
|
160
|
+
return diagnostics
|