simple-module-hosting 0.0.30__tar.gz → 0.0.32__tar.gz
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-0.0.30 → simple_module_hosting-0.0.32}/.gitignore +1 -0
- {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.32}/PKG-INFO +4 -4
- {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.32}/pyproject.toml +3 -3
- simple_module_hosting-0.0.32/simple_module_hosting/_db_health.py +56 -0
- simple_module_hosting-0.0.32/simple_module_hosting/_error_handlers.py +159 -0
- simple_module_hosting-0.0.32/simple_module_hosting/_inertia_cache.py +104 -0
- simple_module_hosting-0.0.32/simple_module_hosting/_inertia_json.py +86 -0
- {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.32}/simple_module_hosting/_inertia_setup.py +5 -1
- {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.32}/simple_module_hosting/_phase_helpers.py +38 -10
- simple_module_hosting-0.0.32/simple_module_hosting/_registrations.py +69 -0
- {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.32}/simple_module_hosting/app_builder.py +30 -26
- {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.32}/simple_module_hosting/assets.py +20 -4
- {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.32}/simple_module_hosting/bootstrap_settings.py +49 -1
- simple_module_hosting-0.0.32/simple_module_hosting/csrf.py +98 -0
- {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.32}/simple_module_hosting/health.py +5 -1
- {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.32}/simple_module_hosting/middleware.py +5 -2
- simple_module_hosting-0.0.32/tests/test_api_error_negotiation.py +143 -0
- {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.32}/tests/test_auth_provider_setting.py +3 -0
- simple_module_hosting-0.0.32/tests/test_csp_module_sources.py +68 -0
- simple_module_hosting-0.0.32/tests/test_db_health_check.py +40 -0
- {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.32}/tests/test_error_page_shared_props.py +13 -0
- simple_module_hosting-0.0.32/tests/test_hosting_csrf.py +98 -0
- simple_module_hosting-0.0.32/tests/test_inertia_cache.py +187 -0
- simple_module_hosting-0.0.32/tests/test_inertia_json_encoder.py +83 -0
- {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.32}/tests/test_middleware_order.py +17 -1
- {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.32}/tests/test_settings_i18n.py +3 -0
- simple_module_hosting-0.0.32/tests/test_sqlite_path_anchor.py +182 -0
- simple_module_hosting-0.0.30/simple_module_hosting/_error_handlers.py +0 -75
- {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.32}/LICENSE +0 -0
- {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.32}/README.md +0 -0
- {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.32}/simple_module_hosting/__init__.py +0 -0
- {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.32}/simple_module_hosting/__main__.py +0 -0
- {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.32}/simple_module_hosting/_host_services.py +0 -0
- {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.32}/simple_module_hosting/_hydrate_step.py +0 -0
- {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.32}/simple_module_hosting/_inertia_shared.py +0 -0
- {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.32}/simple_module_hosting/_observability.py +0 -0
- {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.32}/simple_module_hosting/host_cli.py +0 -0
- {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.32}/simple_module_hosting/host_settings.py +0 -0
- {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.32}/simple_module_hosting/i18n_deps.py +0 -0
- {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.32}/simple_module_hosting/i18n_manifest.py +0 -0
- {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.32}/simple_module_hosting/i18n_middleware.py +0 -0
- {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.32}/simple_module_hosting/inertia_deps.py +0 -0
- {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.32}/simple_module_hosting/inertia_utils.py +0 -0
- {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.32}/simple_module_hosting/logging.py +0 -0
- {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.32}/simple_module_hosting/manifest.py +0 -0
- {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.32}/simple_module_hosting/migrations.py +0 -0
- {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.32}/simple_module_hosting/permissions.py +0 -0
- {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.32}/simple_module_hosting/py.typed +0 -0
- {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.32}/simple_module_hosting/redirects.py +0 -0
- {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.32}/simple_module_hosting/settings.py +0 -0
- {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.32}/simple_module_hosting/shared_props.py +0 -0
- {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.32}/simple_module_hosting/static_files.py +0 -0
- {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.32}/tests/test_app.py +0 -0
- {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.32}/tests/test_branding_head.py +0 -0
- {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.32}/tests/test_check_migrations.py +0 -0
- {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.32}/tests/test_design_packs_wiring.py +0 -0
- {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.32}/tests/test_health.py +0 -0
- {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.32}/tests/test_host_cli.py +0 -0
- {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.32}/tests/test_hosting_permissions.py +0 -0
- {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.32}/tests/test_i18n_manifest.py +0 -0
- {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.32}/tests/test_inertia_i18n_shared_props.py +0 -0
- {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.32}/tests/test_inertia_manifest.py +0 -0
- {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.32}/tests/test_inertia_shared_providers.py +0 -0
- {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.32}/tests/test_lifespan_order.py +0 -0
- {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.32}/tests/test_locale_middleware.py +0 -0
- {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.32}/tests/test_logging.py +0 -0
- {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.32}/tests/test_manifest.py +0 -0
- {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.32}/tests/test_menu_urls_are_canonical.py +0 -0
- {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.32}/tests/test_precompressed_static.py +0 -0
- {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.32}/tests/test_proxy_headers.py +0 -0
- {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.32}/tests/test_redirects.py +0 -0
- {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.32}/tests/test_response_compression.py +0 -0
- {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.32}/tests/test_session_cookie_security.py +0 -0
- {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.32}/tests/test_settings_secrets.py +0 -0
- {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.32}/tests/test_static_caching.py +0 -0
- {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.32}/tests/test_strict_discovery_wiring.py +0 -0
- {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.32}/tests/test_tenant_middleware.py +0 -0
- {simple_module_hosting-0.0.30 → simple_module_hosting-0.0.32}/tests/test_translator_dep.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: simple_module_hosting
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.32
|
|
4
4
|
Summary: FastAPI + Inertia.js host runtime for simple_module — app_builder, middleware stack, CLI (sm / simple-module), scaffolding
|
|
5
5
|
Project-URL: Homepage, https://github.com/antosubash/simple_module_python
|
|
6
6
|
Project-URL: Repository, https://github.com/antosubash/simple_module_python
|
|
@@ -27,8 +27,8 @@ Requires-Dist: fastapi>=0.115
|
|
|
27
27
|
Requires-Dist: httpx>=0.27
|
|
28
28
|
Requires-Dist: itsdangerous>=2.0
|
|
29
29
|
Requires-Dist: jinja2>=3.1
|
|
30
|
-
Requires-Dist: simple-module-core==0.0.
|
|
31
|
-
Requires-Dist: simple-module-db==0.0.
|
|
30
|
+
Requires-Dist: simple-module-core==0.0.32
|
|
31
|
+
Requires-Dist: simple-module-db==0.0.32
|
|
32
32
|
Requires-Dist: starlette>=0.44
|
|
33
33
|
Requires-Dist: tomlkit>=0.13
|
|
34
34
|
Requires-Dist: uvicorn[standard]>=0.34
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "simple_module_hosting"
|
|
3
|
-
version = "0.0.
|
|
3
|
+
version = "0.0.32"
|
|
4
4
|
description = "FastAPI + Inertia.js host runtime for simple_module — app_builder, middleware stack, CLI (sm / simple-module), scaffolding"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "MIT"
|
|
@@ -30,8 +30,8 @@ dependencies = [
|
|
|
30
30
|
# it directly — out-of-tree hosts have nothing else pulling it in.
|
|
31
31
|
"itsdangerous>=2.0",
|
|
32
32
|
"jinja2>=3.1",
|
|
33
|
-
"simple_module_core==0.0.
|
|
34
|
-
"simple_module_db==0.0.
|
|
33
|
+
"simple_module_core==0.0.32",
|
|
34
|
+
"simple_module_db==0.0.32",
|
|
35
35
|
"starlette>=0.44",
|
|
36
36
|
"tomlkit>=0.13",
|
|
37
37
|
"uvicorn[standard]>=0.34",
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""The host's own readiness check: can we reach the database?
|
|
2
|
+
|
|
3
|
+
Registered by ``create_app`` rather than by a module, because it is the one
|
|
4
|
+
dependency no request can do without — unlike a mailer or an object store,
|
|
5
|
+
which a page load never touches.
|
|
6
|
+
|
|
7
|
+
It also gives ``/health/ready`` something to actually report. Modules
|
|
8
|
+
contribute checks for third-party services, and those are ``probe=False``
|
|
9
|
+
(polling an SMTP login every 10s earns a rate-limit), so without this the
|
|
10
|
+
readiness endpoint would answer "healthy" from an empty check set — a green
|
|
11
|
+
light that proves nothing.
|
|
12
|
+
|
|
13
|
+
It is owned by the host, not a module, so it deliberately does not light up any
|
|
14
|
+
tile on the dashboard's per-module health strip — those are keyed by
|
|
15
|
+
``HealthCheck.module``.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from simple_module_core.health import HealthCheck, HealthCheckResult, HealthStatus
|
|
21
|
+
from sqlalchemy import text
|
|
22
|
+
|
|
23
|
+
CHECK_DATABASE = "host.database"
|
|
24
|
+
|
|
25
|
+
_MODULE = "Host"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def build_database_check(db_state):
|
|
29
|
+
"""Return an async check issuing the cheapest possible round trip."""
|
|
30
|
+
|
|
31
|
+
async def check() -> HealthCheckResult:
|
|
32
|
+
try:
|
|
33
|
+
async with db_state.session_factory() as session:
|
|
34
|
+
await session.execute(text("SELECT 1"))
|
|
35
|
+
except Exception as exc:
|
|
36
|
+
return HealthCheckResult(status=HealthStatus.UNHEALTHY, detail=str(exc))
|
|
37
|
+
return HealthCheckResult(status=HealthStatus.HEALTHY, detail="Database reachable")
|
|
38
|
+
|
|
39
|
+
return check
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def register_database_check(health_registry, db_state) -> None:
|
|
43
|
+
"""Add the database check to *health_registry*.
|
|
44
|
+
|
|
45
|
+
``probe=True``: a ``SELECT 1`` against an already-pooled connection is
|
|
46
|
+
cheap enough to run on a probe timer, and it is exactly what a readiness
|
|
47
|
+
probe should be asking.
|
|
48
|
+
"""
|
|
49
|
+
health_registry.add(
|
|
50
|
+
HealthCheck(
|
|
51
|
+
name=CHECK_DATABASE,
|
|
52
|
+
check=build_database_check(db_state),
|
|
53
|
+
module=_MODULE,
|
|
54
|
+
probe=True,
|
|
55
|
+
)
|
|
56
|
+
)
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""Framework-wide exception handlers that render Inertia error pages."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
|
|
7
|
+
from fastapi.encoders import jsonable_encoder
|
|
8
|
+
from fastapi.exceptions import RequestValidationError
|
|
9
|
+
from fastapi.responses import JSONResponse
|
|
10
|
+
from inertia import (
|
|
11
|
+
Inertia,
|
|
12
|
+
InertiaConfig,
|
|
13
|
+
InertiaVersionConflictException,
|
|
14
|
+
inertia_version_conflict_exception_handler,
|
|
15
|
+
)
|
|
16
|
+
from simple_module_core.exceptions import NotFoundError
|
|
17
|
+
from starlette.exceptions import HTTPException
|
|
18
|
+
from starlette.requests import Request
|
|
19
|
+
from starlette.responses import Response
|
|
20
|
+
|
|
21
|
+
from simple_module_hosting._inertia_shared import _INERTIA_HEADER
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
_INERTIA_ERROR_STATUSES = frozenset({403, 404, 500})
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _explicit_accept_q(accept: str, media_type: str) -> float | None:
|
|
29
|
+
"""Quality the Accept header gives ``media_type`` by explicit listing, or
|
|
30
|
+
``None`` when the caller never named it at all.
|
|
31
|
+
|
|
32
|
+
Only exact entries count (no ``*/*`` / ``type/*`` wildcards) — the
|
|
33
|
+
negotiation below cares whether the caller *named* html or json, and at
|
|
34
|
+
what preference, and separately whether they named it *at all* (``None``
|
|
35
|
+
vs. an explicit ``q=0``). Malformed q-values fall back to 1.0 per RFC 9110.
|
|
36
|
+
"""
|
|
37
|
+
for part in accept.split(","):
|
|
38
|
+
media, _, params = part.partition(";")
|
|
39
|
+
if media.strip().lower() != media_type:
|
|
40
|
+
continue
|
|
41
|
+
q = 1.0
|
|
42
|
+
for param in params.split(";"):
|
|
43
|
+
name, _, value = param.partition("=")
|
|
44
|
+
if name.strip().lower() == "q":
|
|
45
|
+
try:
|
|
46
|
+
q = float(value.strip())
|
|
47
|
+
except ValueError:
|
|
48
|
+
q = 1.0
|
|
49
|
+
return q
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _wants_json(request: Request) -> bool:
|
|
54
|
+
"""API callers get JSON error bodies; browser-shaped requests get the page.
|
|
55
|
+
|
|
56
|
+
A request that explicitly accepts ``text/html`` is a browser navigation —
|
|
57
|
+
and those reach ``/api/*`` too (OAuth login links, file-download hrefs) —
|
|
58
|
+
so it always gets the rendered page, unless the caller *prefers* JSON via
|
|
59
|
+
q-values (``application/json, text/html;q=0.5`` is an API client keeping
|
|
60
|
+
an html fallback). So does an Inertia visit (``X-Inertia`` header),
|
|
61
|
+
whatever its Accept — an Inertia client can only consume Inertia-protocol
|
|
62
|
+
responses, never a bare JSON body. Otherwise ``/api/*``, the documented
|
|
63
|
+
prefix for every module's JSON surface (``ModuleMeta.route_prefix``),
|
|
64
|
+
gets JSON — a bare ``fetch()`` sends ``Accept: */*`` — as does an
|
|
65
|
+
explicit ``Accept: application/json`` anywhere else.
|
|
66
|
+
|
|
67
|
+
``text/html;q=0`` rules html out *everywhere*, not just under ``/api/*``:
|
|
68
|
+
once the caller has explicitly said they won't accept html, this never
|
|
69
|
+
chooses the html page. The (already-moot) html fallback survives only
|
|
70
|
+
when json wasn't mentioned either — there's nothing else to offer, so a
|
|
71
|
+
view path keeps its pre-existing default and an ``/api/*`` path keeps
|
|
72
|
+
getting JSON either way.
|
|
73
|
+
"""
|
|
74
|
+
if request.headers.get(_INERTIA_HEADER):
|
|
75
|
+
return False
|
|
76
|
+
accept = request.headers.get("accept", "")
|
|
77
|
+
html_q = _explicit_accept_q(accept, "text/html")
|
|
78
|
+
json_q = _explicit_accept_q(accept, "application/json")
|
|
79
|
+
if html_q is not None and html_q > 0 and html_q >= (json_q or 0.0):
|
|
80
|
+
return False
|
|
81
|
+
path = request.url.path
|
|
82
|
+
is_api = path == "/api" or path.startswith("/api/")
|
|
83
|
+
if html_q == 0:
|
|
84
|
+
return is_api if json_q is None else True
|
|
85
|
+
return is_api or bool(json_q)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
async def render_error_page(request: Request, status_code: int, message: str) -> Response:
|
|
89
|
+
config: InertiaConfig = request.app.state.sm.inertia_config
|
|
90
|
+
try:
|
|
91
|
+
inertia = Inertia(request, config)
|
|
92
|
+
# This builds its own Inertia instead of going through get_inertia, so
|
|
93
|
+
# the share step has to be repeated here. Without it the error page
|
|
94
|
+
# renders raw translation keys (host.error.not_found_title) and loses
|
|
95
|
+
# auth/menus, so a signed-in user's 404 has no layout.
|
|
96
|
+
shared = getattr(request.state, "inertia_shared", None)
|
|
97
|
+
if shared:
|
|
98
|
+
inertia.share(**shared)
|
|
99
|
+
# The correlation id is the only handle a user has on their own failed
|
|
100
|
+
# request — without it a support report is just "it broke". It is already
|
|
101
|
+
# on every log line for this request, so quoting it back makes the page
|
|
102
|
+
# and the logs joinable.
|
|
103
|
+
response = await inertia.render(
|
|
104
|
+
"Error",
|
|
105
|
+
{
|
|
106
|
+
"status": status_code,
|
|
107
|
+
"message": message,
|
|
108
|
+
"correlation_id": getattr(request.state, "correlation_id", "") or "",
|
|
109
|
+
},
|
|
110
|
+
)
|
|
111
|
+
response.status_code = status_code
|
|
112
|
+
return response
|
|
113
|
+
except InertiaVersionConflictException as exc:
|
|
114
|
+
return await inertia_version_conflict_exception_handler(request, exc)
|
|
115
|
+
except Exception:
|
|
116
|
+
# Fallback if Inertia rendering itself fails (e.g. missing session)
|
|
117
|
+
logger.exception("Error page rendering failed, falling back to JSON")
|
|
118
|
+
return JSONResponse(
|
|
119
|
+
status_code=status_code, content={"detail": message or "Internal Server Error"}
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
async def http_exception_handler(request: Request, exc: HTTPException) -> Response:
|
|
124
|
+
if exc.status_code in _INERTIA_ERROR_STATUSES and not _wants_json(request):
|
|
125
|
+
detail = str(exc.detail) if exc.detail else ""
|
|
126
|
+
return await render_error_page(request, exc.status_code, detail)
|
|
127
|
+
# Preserve exception headers (WWW-Authenticate, Retry-After, ...) the way
|
|
128
|
+
# FastAPI's stock handler does.
|
|
129
|
+
return JSONResponse(
|
|
130
|
+
status_code=exc.status_code,
|
|
131
|
+
content={"detail": exc.detail},
|
|
132
|
+
headers=getattr(exc, "headers", None),
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
async def not_found_error_handler(request: Request, exc: NotFoundError) -> Response:
|
|
137
|
+
if _wants_json(request):
|
|
138
|
+
return JSONResponse(status_code=404, content={"detail": str(exc)})
|
|
139
|
+
return await render_error_page(request, 404, str(exc))
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
async def request_validation_error_handler(
|
|
143
|
+
request: Request, exc: RequestValidationError
|
|
144
|
+
) -> Response:
|
|
145
|
+
"""Return an Inertia error page for browser requests with invalid params.
|
|
146
|
+
|
|
147
|
+
Same negotiation rule as 403/404/500 (``_wants_json``), so one request
|
|
148
|
+
never sees two different error shapes depending on the error class.
|
|
149
|
+
"""
|
|
150
|
+
if _wants_json(request):
|
|
151
|
+
return JSONResponse(status_code=422, content={"detail": jsonable_encoder(exc.errors())})
|
|
152
|
+
return await render_error_page(request, 422, "The requested URL contains invalid parameters.")
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
async def unhandled_exception_handler(request: Request, exc: Exception) -> Response:
|
|
156
|
+
logger.exception("Unhandled exception: %s", exc)
|
|
157
|
+
if _wants_json(request):
|
|
158
|
+
return JSONResponse(status_code=500, content={"detail": "Internal Server Error"})
|
|
159
|
+
return await render_error_page(request, 500, "")
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Keep the Inertia payload out of caches that answer page requests.
|
|
2
|
+
|
|
3
|
+
Every Inertia route serves one URL as two representations, chosen on the
|
|
4
|
+
request's ``X-Inertia`` header: an HTML document for a full page load, a JSON
|
|
5
|
+
payload for a client-side visit. Nothing in either response says so, which
|
|
6
|
+
leaves a cache free to store one and hand it back for the other. Visit a page
|
|
7
|
+
through the SPA, then open the same URL directly, and the browser can serve the
|
|
8
|
+
stored payload as the document — the visitor gets
|
|
9
|
+
``{"component":"...","props":{...}}`` where the page should be.
|
|
10
|
+
|
|
11
|
+
That only bites once a route marks itself cacheable, which a public-content
|
|
12
|
+
module reasonably does. The framework is what makes it unsafe:
|
|
13
|
+
:class:`~simple_module_hosting.middleware.InertiaLayoutDataMiddleware` merges
|
|
14
|
+
the signed-in user's ``auth`` block, their permission list and the menus their
|
|
15
|
+
roles resolve to into *every* Inertia payload. A route author choosing
|
|
16
|
+
``Cache-Control: public`` for their page content has no way to know that, so
|
|
17
|
+
the guarantee belongs here rather than in each module:
|
|
18
|
+
|
|
19
|
+
* **An Inertia payload is never stored.** ``private, no-store``, and the ETag
|
|
20
|
+
is dropped so no cache can revalidate its way back to a copy it should not
|
|
21
|
+
have kept. The cost is per-visit caching on client-side navigation, which was
|
|
22
|
+
never safe to take — those bytes are specific to one user.
|
|
23
|
+
* **Both representations declare ``Vary: X-Inertia``**, so a cache that honours
|
|
24
|
+
Vary keeps them in separate entries instead of inferring it from ``Accept``.
|
|
25
|
+
|
|
26
|
+
``Vary`` is added to the document only when the response is HTML, so static
|
|
27
|
+
assets and JSON APIs keep the validators and cache entries they had.
|
|
28
|
+
|
|
29
|
+
A module that wants its public page content cached should give the *document*
|
|
30
|
+
its own ``Cache-Control`` and an ETag that identifies the representation; this
|
|
31
|
+
middleware leaves that path alone and only governs the payload.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
from starlette.datastructures import MutableHeaders
|
|
37
|
+
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
|
38
|
+
|
|
39
|
+
_SCOPE_HTTP = "http"
|
|
40
|
+
_HEADER_INERTIA = b"x-inertia"
|
|
41
|
+
|
|
42
|
+
#: What an Inertia payload is allowed to say about its own cacheability.
|
|
43
|
+
PAYLOAD_CACHE_CONTROL = "private, no-store"
|
|
44
|
+
|
|
45
|
+
#: The request header that selects the representation, and therefore the one
|
|
46
|
+
#: caches must key on.
|
|
47
|
+
VARY_FIELD = "X-Inertia"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def add_vary(headers: MutableHeaders, field: str = VARY_FIELD) -> None:
|
|
51
|
+
"""Add a field to ``Vary``, keeping whatever is already listed."""
|
|
52
|
+
existing = headers.get("vary")
|
|
53
|
+
if not existing:
|
|
54
|
+
headers["vary"] = field
|
|
55
|
+
return
|
|
56
|
+
if any(part.strip().lower() == field.lower() for part in existing.split(",")):
|
|
57
|
+
return
|
|
58
|
+
headers["vary"] = f"{existing}, {field}"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def is_inertia_request(scope: Scope) -> bool:
|
|
62
|
+
"""Whether this request asked for the JSON representation.
|
|
63
|
+
|
|
64
|
+
Mirrors ``fastapi-inertia``'s own ``Inertia._is_inertia_request`` exactly —
|
|
65
|
+
presence of the header, any value — rather than requiring it to equal
|
|
66
|
+
``"true"``. The library renders JSON for *any* ``X-Inertia`` value, so a
|
|
67
|
+
stricter check here would disagree with it: a request the renderer treats
|
|
68
|
+
as Inertia would sail through uncached, undoing the whole fix.
|
|
69
|
+
"""
|
|
70
|
+
return any(key == _HEADER_INERTIA for key, _ in scope.get("headers", ()))
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _is_html(headers: MutableHeaders) -> bool:
|
|
74
|
+
return headers.get("content-type", "").split(";", 1)[0].strip() == "text/html"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class InertiaCacheMiddleware:
|
|
78
|
+
"""Stop an Inertia payload being cached as though it were the page."""
|
|
79
|
+
|
|
80
|
+
def __init__(self, app: ASGIApp) -> None:
|
|
81
|
+
self.app = app
|
|
82
|
+
|
|
83
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
84
|
+
if scope["type"] != _SCOPE_HTTP:
|
|
85
|
+
await self.app(scope, receive, send)
|
|
86
|
+
return
|
|
87
|
+
|
|
88
|
+
inertia = is_inertia_request(scope)
|
|
89
|
+
|
|
90
|
+
async def send_with_cache_rules(message: Message) -> None:
|
|
91
|
+
if message["type"] == "http.response.start":
|
|
92
|
+
headers = MutableHeaders(scope=message)
|
|
93
|
+
if inertia:
|
|
94
|
+
headers["cache-control"] = PAYLOAD_CACHE_CONTROL
|
|
95
|
+
del headers["etag"]
|
|
96
|
+
add_vary(headers)
|
|
97
|
+
elif _is_html(headers):
|
|
98
|
+
add_vary(headers)
|
|
99
|
+
await send(message)
|
|
100
|
+
|
|
101
|
+
await self.app(scope, receive, send_with_cache_rules)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
__all__ = ["PAYLOAD_CACHE_CONTROL", "VARY_FIELD", "InertiaCacheMiddleware", "add_vary"]
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Make Inertia's JSON branch encode props the way its HTML branch does.
|
|
2
|
+
|
|
3
|
+
``fastapi-inertia`` renders the same props two ways and configures only one of
|
|
4
|
+
them::
|
|
5
|
+
|
|
6
|
+
# full page load — honours InertiaConfig.json_encoder
|
|
7
|
+
json_string = json.dumps(page_data, cls=self._config.json_encoder)
|
|
8
|
+
|
|
9
|
+
# client-side visit — Starlette's JSONResponse, so plain json.dumps
|
|
10
|
+
return JSONResponse(content=await self._get_page_data(), ...)
|
|
11
|
+
|
|
12
|
+
``InertiaJsonEncoder`` exists precisely to run the payload through FastAPI's
|
|
13
|
+
``jsonable_encoder``, and the second branch never reaches it. The effect is a
|
|
14
|
+
route that works on a reload and 500s on every client-side visit, for any prop
|
|
15
|
+
the stdlib encoder cannot handle — ``Path``, ``Decimal``, ``UUID``, a dataclass,
|
|
16
|
+
an enum.
|
|
17
|
+
|
|
18
|
+
Settings → Modules is where this surfaced: it reflects each installed module's
|
|
19
|
+
pydantic settings back to the admin, and a module whose settings carry a
|
|
20
|
+
``Path`` field (``PagebuilderSettings.media_root``) puts a ``PosixPath`` in the
|
|
21
|
+
payload::
|
|
22
|
+
|
|
23
|
+
TypeError: Object of type PosixPath is not JSON serializable
|
|
24
|
+
|
|
25
|
+
Fixing it here rather than in each module is deliberate. A module putting a
|
|
26
|
+
rich value in props is not doing anything wrong — the HTML branch has always
|
|
27
|
+
accepted it — so the asymmetry is the defect, and closing it at the point of
|
|
28
|
+
serialisation covers every module and every prop type at once.
|
|
29
|
+
|
|
30
|
+
Applied by wrapping the dependency published on ``app.state.inertia_dependency``,
|
|
31
|
+
which ``inertia_deps.get_inertia`` calls once per request. The replacement binds
|
|
32
|
+
to the instance, so the library's class is untouched and any other construction
|
|
33
|
+
path keeps the stock behaviour.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
import logging
|
|
39
|
+
from typing import Any
|
|
40
|
+
|
|
41
|
+
from fastapi.encoders import jsonable_encoder
|
|
42
|
+
from starlette.responses import JSONResponse
|
|
43
|
+
|
|
44
|
+
logger = logging.getLogger(__name__)
|
|
45
|
+
|
|
46
|
+
#: The headers upstream's ``_render_json`` sets, reproduced so the wrap is a
|
|
47
|
+
#: pure encoder change. ``InertiaCacheMiddleware`` owns the caching rules and
|
|
48
|
+
#: extends ``Vary`` on the way out.
|
|
49
|
+
_JSON_HEADERS = {"X-Inertia": "true", "Vary": "Accept"}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def json_safe_inertia_dependency(inertia_dep: Any) -> Any:
|
|
53
|
+
"""Wrap an Inertia dependency so its JSON branch uses ``jsonable_encoder``.
|
|
54
|
+
|
|
55
|
+
The wrapped callable keeps the ``(request, client)`` shape
|
|
56
|
+
``inertia_dependency_factory`` returns. An instance that doesn't expose the
|
|
57
|
+
private hook this relies on is handed back untouched: the failure mode is
|
|
58
|
+
the 500 that already happens, and refusing to boot because an upstream
|
|
59
|
+
attribute moved would be a worse trade.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
def dependency(request: Any, client: Any = None) -> Any:
|
|
63
|
+
inertia = inertia_dep(request, client)
|
|
64
|
+
if hasattr(inertia, "_get_page_data"):
|
|
65
|
+
inertia._render_json = _render_json_for(inertia)
|
|
66
|
+
else: # pragma: no cover - upstream layout changed
|
|
67
|
+
logger.warning(
|
|
68
|
+
"Inertia instance has no _get_page_data; JSON responses keep the "
|
|
69
|
+
"stock encoder and non-JSON-native props will fail to serialise"
|
|
70
|
+
)
|
|
71
|
+
return inertia
|
|
72
|
+
|
|
73
|
+
return dependency
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _render_json_for(inertia: Any) -> Any:
|
|
77
|
+
"""Build the instance's replacement ``_render_json``."""
|
|
78
|
+
|
|
79
|
+
async def _render_json() -> JSONResponse:
|
|
80
|
+
page_data = await inertia._get_page_data()
|
|
81
|
+
return JSONResponse(content=jsonable_encoder(page_data), headers=_JSON_HEADERS)
|
|
82
|
+
|
|
83
|
+
return _render_json
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
__all__ = ["json_safe_inertia_dependency"]
|
|
@@ -12,6 +12,7 @@ from fastapi import FastAPI
|
|
|
12
12
|
from inertia import InertiaConfig, inertia_dependency_factory
|
|
13
13
|
from starlette.requests import Request
|
|
14
14
|
|
|
15
|
+
from simple_module_hosting._inertia_json import json_safe_inertia_dependency
|
|
15
16
|
from simple_module_hosting.settings import Settings
|
|
16
17
|
|
|
17
18
|
logger = logging.getLogger(__name__)
|
|
@@ -184,6 +185,9 @@ def setup_inertia(
|
|
|
184
185
|
use_flash_errors=True,
|
|
185
186
|
)
|
|
186
187
|
|
|
187
|
-
|
|
188
|
+
# Upstream's JSON branch builds a Starlette JSONResponse directly, so the
|
|
189
|
+
# encoder configured above only ever applies to full page loads. Wrap the
|
|
190
|
+
# dependency so a client-side visit encodes the same props the same way.
|
|
191
|
+
inertia_dep = json_safe_inertia_dependency(inertia_dependency_factory(inertia_config))
|
|
188
192
|
app.state.inertia_dependency = inertia_dep
|
|
189
193
|
return inertia_config
|
|
@@ -21,6 +21,7 @@ from inertia import (
|
|
|
21
21
|
)
|
|
22
22
|
from simple_module_core.diagnostics import Diagnostic, DiagnosticLevel
|
|
23
23
|
from simple_module_core.exceptions import NotFoundError
|
|
24
|
+
from simple_module_db import CommitBeforeResponseMiddleware
|
|
24
25
|
from starlette.exceptions import HTTPException
|
|
25
26
|
from starlette.middleware.gzip import GZipMiddleware
|
|
26
27
|
from starlette.middleware.sessions import SessionMiddleware
|
|
@@ -33,6 +34,7 @@ from simple_module_hosting._error_handlers import (
|
|
|
33
34
|
unhandled_exception_handler,
|
|
34
35
|
)
|
|
35
36
|
from simple_module_hosting._host_services import _HostServices
|
|
37
|
+
from simple_module_hosting._inertia_cache import InertiaCacheMiddleware
|
|
36
38
|
from simple_module_hosting.host_settings import HostSettings
|
|
37
39
|
from simple_module_hosting.i18n_middleware import LocaleMiddleware
|
|
38
40
|
from simple_module_hosting.middleware import (
|
|
@@ -46,6 +48,7 @@ from simple_module_hosting.settings import Settings
|
|
|
46
48
|
from simple_module_hosting.static_files import PrecompressedStaticFiles
|
|
47
49
|
|
|
48
50
|
if TYPE_CHECKING:
|
|
51
|
+
from simple_module_core.csp import CspSourceRegistry
|
|
49
52
|
from simple_module_core.menu import MenuRegistry
|
|
50
53
|
from simple_module_core.permissions import PermissionRegistry
|
|
51
54
|
|
|
@@ -72,19 +75,46 @@ def register_exception_handlers(app: FastAPI, modules: list) -> None:
|
|
|
72
75
|
mod.register_exception_handlers(app)
|
|
73
76
|
|
|
74
77
|
|
|
78
|
+
def build_csp(settings: Settings, csp_registry: CspSourceRegistry) -> str:
|
|
79
|
+
"""Choose the CSP for this boot and fold in module-declared sources.
|
|
80
|
+
|
|
81
|
+
Development gets the Vite-widened policy; production the strict default.
|
|
82
|
+
``csp_registry`` carries origins modules declared via
|
|
83
|
+
``register_csp_sources`` (e.g. an external font host) — merged here so
|
|
84
|
+
both variants honor them. An empty registry leaves the policy unchanged.
|
|
85
|
+
"""
|
|
86
|
+
base = (
|
|
87
|
+
SecurityHeadersMiddleware.dev_csp(settings.vite_dev_url)
|
|
88
|
+
if settings.is_development
|
|
89
|
+
else SecurityHeadersMiddleware.DEFAULT_CSP
|
|
90
|
+
)
|
|
91
|
+
return csp_registry.extend_policy(base)
|
|
92
|
+
|
|
93
|
+
|
|
75
94
|
def install_middleware(
|
|
76
95
|
app: FastAPI,
|
|
77
96
|
settings: Settings,
|
|
78
97
|
modules: list,
|
|
79
98
|
menu_registry: MenuRegistry,
|
|
80
99
|
perm_registry: PermissionRegistry,
|
|
100
|
+
csp_registry: CspSourceRegistry,
|
|
81
101
|
) -> None:
|
|
82
102
|
"""Install the full middleware pipeline.
|
|
83
103
|
|
|
84
104
|
Order matters: last added = first executed. Execution order:
|
|
85
105
|
(ProxyHeaders, if trusted_proxy) → CorrelationId → RequestLogging
|
|
86
|
-
→ Security → Session → [module] → (Tenant, if multi_tenant) → Locale
|
|
106
|
+
→ Security → Session → [module] → (Tenant, if multi_tenant) → Locale
|
|
107
|
+
→ Inertia → InertiaCache → CommitBeforeResponse.
|
|
87
108
|
"""
|
|
109
|
+
# Added first, so it is innermost and its send-wrapper is the first to see
|
|
110
|
+
# the response: the request's DB work commits before any byte reaches the
|
|
111
|
+
# client, instead of in get_db's post-response exit code (GH #257).
|
|
112
|
+
app.add_middleware(CommitBeforeResponseMiddleware)
|
|
113
|
+
# Paired with InertiaLayoutDataMiddleware below, which is what puts this
|
|
114
|
+
# user's auth, permissions and menus into every Inertia payload: this one
|
|
115
|
+
# makes sure the payload that results is never stored where a page request
|
|
116
|
+
# can be answered with it.
|
|
117
|
+
app.add_middleware(InertiaCacheMiddleware)
|
|
88
118
|
app.add_middleware(
|
|
89
119
|
InertiaLayoutDataMiddleware,
|
|
90
120
|
menu_registry=menu_registry,
|
|
@@ -102,16 +132,14 @@ def install_middleware(
|
|
|
102
132
|
mod.register_middleware(app)
|
|
103
133
|
app.add_middleware(SessionMiddleware, secret_key=settings.secret_key)
|
|
104
134
|
# In dev, relax CSP so the browser can fetch @vite/client, main.tsx, and
|
|
105
|
-
# the HMR WebSocket from the Vite origin
|
|
106
|
-
# dev runs over plain HTTP on loopback.
|
|
135
|
+
# the HMR WebSocket from the Vite origin (build_csp picks the variant).
|
|
136
|
+
# HSTS is suppressed in dev because it runs over plain HTTP on loopback.
|
|
137
|
+
security_kwargs: dict[str, str | None] = {
|
|
138
|
+
"content_security_policy": build_csp(settings, csp_registry)
|
|
139
|
+
}
|
|
107
140
|
if settings.is_development:
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
content_security_policy=SecurityHeadersMiddleware.dev_csp(settings.vite_dev_url),
|
|
111
|
-
strict_transport_security=None,
|
|
112
|
-
)
|
|
113
|
-
else:
|
|
114
|
-
app.add_middleware(SecurityHeadersMiddleware)
|
|
141
|
+
security_kwargs["strict_transport_security"] = None
|
|
142
|
+
app.add_middleware(SecurityHeadersMiddleware, **security_kwargs)
|
|
115
143
|
# Compress response bodies. Added here so it sits inside CorrelationId and
|
|
116
144
|
# RequestLogging (which set headers and read request state) but outside
|
|
117
145
|
# everything that produces a body — including the /static mount, where it
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Phase 5 of boot — every module's declarative registration hooks.
|
|
2
|
+
|
|
3
|
+
Extracted from ``app_builder.py`` to keep that file readable; this is the one
|
|
4
|
+
place that knows the full set of hooks a module may implement, and the order
|
|
5
|
+
they run in. ``app_builder.create_app`` is the only intended caller.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import inspect
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from fastapi import FastAPI
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def run_module_registrations(
|
|
18
|
+
modules: list,
|
|
19
|
+
*,
|
|
20
|
+
app: FastAPI,
|
|
21
|
+
event_bus,
|
|
22
|
+
menu_registry,
|
|
23
|
+
perm_registry,
|
|
24
|
+
ff_registry,
|
|
25
|
+
health_registry,
|
|
26
|
+
public_route_registry,
|
|
27
|
+
design_pack_registry,
|
|
28
|
+
audit_link_registry,
|
|
29
|
+
csp_registry,
|
|
30
|
+
) -> None:
|
|
31
|
+
"""Invoke each module's registration hooks, in dependency order.
|
|
32
|
+
|
|
33
|
+
Health checks are attributed to the module that registers them, so the
|
|
34
|
+
dashboard can report health per module rather than one global number. The
|
|
35
|
+
owner is cleared afterwards: anything registered later — a module's
|
|
36
|
+
``on_startup``, say — belongs to no module in this loop, and inheriting
|
|
37
|
+
the last one's name would be a lie.
|
|
38
|
+
"""
|
|
39
|
+
for mod in modules:
|
|
40
|
+
mod.register_menu_items(menu_registry)
|
|
41
|
+
mod.register_permissions(perm_registry)
|
|
42
|
+
mod.register_feature_flags(ff_registry)
|
|
43
|
+
dispatch_event_handlers(mod, event_bus, app)
|
|
44
|
+
health_registry.set_owner(mod.meta.name)
|
|
45
|
+
mod.register_health_checks(health_registry)
|
|
46
|
+
mod.register_public_routes(public_route_registry)
|
|
47
|
+
# csp before design packs — the documented lifecycle order
|
|
48
|
+
# (docs/framework/lifecycle.md).
|
|
49
|
+
mod.register_csp_sources(csp_registry)
|
|
50
|
+
mod.register_design_packs(design_pack_registry)
|
|
51
|
+
mod.register_audit_links(audit_link_registry)
|
|
52
|
+
|
|
53
|
+
health_registry.set_owner("")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def dispatch_event_handlers(mod, event_bus, app: FastAPI) -> None:
|
|
57
|
+
"""Call ``mod.register_event_handlers`` with or without ``app``.
|
|
58
|
+
|
|
59
|
+
Back-compat shim for modules that still override the one-arg form
|
|
60
|
+
``(self, bus)``; passing ``app=`` to those crashes.
|
|
61
|
+
"""
|
|
62
|
+
sig = inspect.signature(mod.register_event_handlers)
|
|
63
|
+
accepts_app = "app" in sig.parameters or any(
|
|
64
|
+
p.kind is inspect.Parameter.VAR_KEYWORD for p in sig.parameters.values()
|
|
65
|
+
)
|
|
66
|
+
if accepts_app:
|
|
67
|
+
mod.register_event_handlers(event_bus, app=app)
|
|
68
|
+
else:
|
|
69
|
+
mod.register_event_handlers(event_bus)
|