simple-module-hosting 0.0.31__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.
Files changed (77) hide show
  1. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/PKG-INFO +3 -3
  2. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/pyproject.toml +3 -3
  3. simple_module_hosting-0.0.32/simple_module_hosting/_inertia_cache.py +104 -0
  4. simple_module_hosting-0.0.32/simple_module_hosting/_inertia_json.py +86 -0
  5. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/simple_module_hosting/_inertia_setup.py +5 -1
  6. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/simple_module_hosting/_phase_helpers.py +7 -1
  7. simple_module_hosting-0.0.32/tests/test_inertia_cache.py +187 -0
  8. simple_module_hosting-0.0.32/tests/test_inertia_json_encoder.py +83 -0
  9. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/tests/test_middleware_order.py +9 -1
  10. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/.gitignore +0 -0
  11. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/LICENSE +0 -0
  12. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/README.md +0 -0
  13. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/simple_module_hosting/__init__.py +0 -0
  14. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/simple_module_hosting/__main__.py +0 -0
  15. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/simple_module_hosting/_db_health.py +0 -0
  16. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/simple_module_hosting/_error_handlers.py +0 -0
  17. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/simple_module_hosting/_host_services.py +0 -0
  18. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/simple_module_hosting/_hydrate_step.py +0 -0
  19. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/simple_module_hosting/_inertia_shared.py +0 -0
  20. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/simple_module_hosting/_observability.py +0 -0
  21. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/simple_module_hosting/_registrations.py +0 -0
  22. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/simple_module_hosting/app_builder.py +0 -0
  23. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/simple_module_hosting/assets.py +0 -0
  24. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/simple_module_hosting/bootstrap_settings.py +0 -0
  25. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/simple_module_hosting/csrf.py +0 -0
  26. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/simple_module_hosting/health.py +0 -0
  27. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/simple_module_hosting/host_cli.py +0 -0
  28. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/simple_module_hosting/host_settings.py +0 -0
  29. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/simple_module_hosting/i18n_deps.py +0 -0
  30. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/simple_module_hosting/i18n_manifest.py +0 -0
  31. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/simple_module_hosting/i18n_middleware.py +0 -0
  32. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/simple_module_hosting/inertia_deps.py +0 -0
  33. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/simple_module_hosting/inertia_utils.py +0 -0
  34. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/simple_module_hosting/logging.py +0 -0
  35. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/simple_module_hosting/manifest.py +0 -0
  36. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/simple_module_hosting/middleware.py +0 -0
  37. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/simple_module_hosting/migrations.py +0 -0
  38. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/simple_module_hosting/permissions.py +0 -0
  39. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/simple_module_hosting/py.typed +0 -0
  40. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/simple_module_hosting/redirects.py +0 -0
  41. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/simple_module_hosting/settings.py +0 -0
  42. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/simple_module_hosting/shared_props.py +0 -0
  43. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/simple_module_hosting/static_files.py +0 -0
  44. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/tests/test_api_error_negotiation.py +0 -0
  45. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/tests/test_app.py +0 -0
  46. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/tests/test_auth_provider_setting.py +0 -0
  47. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/tests/test_branding_head.py +0 -0
  48. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/tests/test_check_migrations.py +0 -0
  49. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/tests/test_csp_module_sources.py +0 -0
  50. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/tests/test_db_health_check.py +0 -0
  51. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/tests/test_design_packs_wiring.py +0 -0
  52. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/tests/test_error_page_shared_props.py +0 -0
  53. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/tests/test_health.py +0 -0
  54. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/tests/test_host_cli.py +0 -0
  55. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/tests/test_hosting_csrf.py +0 -0
  56. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/tests/test_hosting_permissions.py +0 -0
  57. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/tests/test_i18n_manifest.py +0 -0
  58. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/tests/test_inertia_i18n_shared_props.py +0 -0
  59. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/tests/test_inertia_manifest.py +0 -0
  60. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/tests/test_inertia_shared_providers.py +0 -0
  61. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/tests/test_lifespan_order.py +0 -0
  62. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/tests/test_locale_middleware.py +0 -0
  63. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/tests/test_logging.py +0 -0
  64. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/tests/test_manifest.py +0 -0
  65. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/tests/test_menu_urls_are_canonical.py +0 -0
  66. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/tests/test_precompressed_static.py +0 -0
  67. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/tests/test_proxy_headers.py +0 -0
  68. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/tests/test_redirects.py +0 -0
  69. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/tests/test_response_compression.py +0 -0
  70. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/tests/test_session_cookie_security.py +0 -0
  71. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/tests/test_settings_i18n.py +0 -0
  72. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/tests/test_settings_secrets.py +0 -0
  73. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/tests/test_sqlite_path_anchor.py +0 -0
  74. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/tests/test_static_caching.py +0 -0
  75. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/tests/test_strict_discovery_wiring.py +0 -0
  76. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/tests/test_tenant_middleware.py +0 -0
  77. {simple_module_hosting-0.0.31 → simple_module_hosting-0.0.32}/tests/test_translator_dep.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: simple_module_hosting
3
- Version: 0.0.31
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
31
- Requires-Dist: simple-module-db==0.0.31
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.31"
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.31",
34
- "simple_module_db==0.0.31",
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,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
- inertia_dep = inertia_dependency_factory(inertia_config)
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
@@ -34,6 +34,7 @@ from simple_module_hosting._error_handlers import (
34
34
  unhandled_exception_handler,
35
35
  )
36
36
  from simple_module_hosting._host_services import _HostServices
37
+ from simple_module_hosting._inertia_cache import InertiaCacheMiddleware
37
38
  from simple_module_hosting.host_settings import HostSettings
38
39
  from simple_module_hosting.i18n_middleware import LocaleMiddleware
39
40
  from simple_module_hosting.middleware import (
@@ -103,12 +104,17 @@ def install_middleware(
103
104
  Order matters: last added = first executed. Execution order:
104
105
  (ProxyHeaders, if trusted_proxy) → CorrelationId → RequestLogging
105
106
  → Security → Session → [module] → (Tenant, if multi_tenant) → Locale
106
- → Inertia → CommitBeforeResponse.
107
+ → Inertia → InertiaCache → CommitBeforeResponse.
107
108
  """
108
109
  # Added first, so it is innermost and its send-wrapper is the first to see
109
110
  # the response: the request's DB work commits before any byte reaches the
110
111
  # client, instead of in get_db's post-response exit code (GH #257).
111
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)
112
118
  app.add_middleware(
113
119
  InertiaLayoutDataMiddleware,
114
120
  menu_registry=menu_registry,
@@ -0,0 +1,187 @@
1
+ """An Inertia payload must never be cacheable as the page it belongs to.
2
+
3
+ The same URL answers twice — HTML for a full page load, JSON for a client-side
4
+ visit — and the only thing separating them is the ``X-Inertia`` request header.
5
+ Left unsaid, a cache stores one and serves it for the other, so opening a page
6
+ directly after visiting it through the SPA renders the raw payload. The payload
7
+ also carries this user's ``auth`` block, permissions and menus, so a shared
8
+ cache storing it is a disclosure bug as well as a broken page.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import httpx
14
+ import pytest
15
+ from simple_module_hosting._inertia_cache import (
16
+ PAYLOAD_CACHE_CONTROL,
17
+ InertiaCacheMiddleware,
18
+ is_inertia_request,
19
+ )
20
+ from starlette.applications import Starlette
21
+ from starlette.responses import HTMLResponse, JSONResponse, Response
22
+ from starlette.routing import Route
23
+
24
+ _OK = 200
25
+ _INERTIA = {"X-Inertia": "true"}
26
+ #: What a public-content module might reasonably put on its own page.
27
+ _PUBLIC_CACHE = "public, max-age=60, stale-while-revalidate=600"
28
+ _SHARED_ETAG = 'W/"deadbeef"'
29
+
30
+
31
+ def _vary_fields(response: httpx.Response) -> set[str]:
32
+ return {part.strip().lower() for part in response.headers.get("vary", "").split(",")}
33
+
34
+
35
+ def _build_app() -> Starlette:
36
+ """A route that markets itself as publicly cacheable, both ways.
37
+
38
+ Mirrors what the page-builder's public viewer does: one ETag and one
39
+ ``Cache-Control`` computed from the page row, stamped on whichever
40
+ representation the request asked for.
41
+ """
42
+
43
+ async def page(request):
44
+ if is_inertia_request(request.scope):
45
+ response: Response = JSONResponse(
46
+ {"component": "PublicPage", "props": {"auth": {"user": "editor"}}},
47
+ headers={"X-Inertia": "true", "Vary": "Accept"},
48
+ )
49
+ else:
50
+ response = HTMLResponse("<!DOCTYPE html><title>Page</title>")
51
+ response.headers["Cache-Control"] = _PUBLIC_CACHE
52
+ response.headers["ETag"] = _SHARED_ETAG
53
+ return response
54
+
55
+ async def asset(request):
56
+ return Response(b"body{}", media_type="text/css", headers={"ETag": _SHARED_ETAG})
57
+
58
+ app = Starlette(routes=[Route("/p/home", page), Route("/static/app.css", asset)])
59
+ app.add_middleware(InertiaCacheMiddleware)
60
+ return app
61
+
62
+
63
+ @pytest.fixture
64
+ def cache_client() -> httpx.AsyncClient:
65
+ return httpx.AsyncClient(
66
+ transport=httpx.ASGITransport(app=_build_app()), base_url="http://test"
67
+ )
68
+
69
+
70
+ class TestInertiaPayload:
71
+ async def test_payload_is_not_stored_even_when_the_route_says_public(
72
+ self, cache_client: httpx.AsyncClient
73
+ ) -> None:
74
+ """The route asked for `public`; the payload is per-user, so it loses."""
75
+ async with cache_client as client:
76
+ resp = await client.get("/p/home", headers=_INERTIA)
77
+
78
+ assert resp.status_code == _OK
79
+ assert resp.headers["cache-control"] == PAYLOAD_CACHE_CONTROL
80
+
81
+ async def test_payload_carries_no_validator(self, cache_client: httpx.AsyncClient) -> None:
82
+ """A shared ETag lets a cache revalidate its way back to the payload.
83
+
84
+ The route stamps the same ETag on both representations, so a client
85
+ holding the JSON could revalidate a *document* request into a 304 and
86
+ keep rendering it. Nothing to revalidate means nothing to restore.
87
+ """
88
+ async with cache_client as client:
89
+ resp = await client.get("/p/home", headers=_INERTIA)
90
+
91
+ assert "etag" not in resp.headers
92
+
93
+ async def test_payload_varies_on_the_header_that_selected_it(
94
+ self, cache_client: httpx.AsyncClient
95
+ ) -> None:
96
+ async with cache_client as client:
97
+ resp = await client.get("/p/home", headers=_INERTIA)
98
+
99
+ assert "x-inertia" in _vary_fields(resp)
100
+ # Whatever the route already listed stays listed.
101
+ assert "accept" in _vary_fields(resp)
102
+
103
+
104
+ class TestMatchesUpstreamDetection:
105
+ """``is_inertia_request`` must agree with ``fastapi-inertia``'s own check.
106
+
107
+ Upstream's ``Inertia._is_inertia_request`` is ``"X-Inertia" in
108
+ self._request.headers`` — presence only, any value. A stricter check here
109
+ (e.g. requiring the value to equal ``"true"``) would disagree with it: a
110
+ request the renderer treats as Inertia and answers with JSON would sail
111
+ through this middleware uncached, reopening the exact leak the fix closes.
112
+ """
113
+
114
+ def test_any_header_value_counts_as_inertia(self) -> None:
115
+ scope = {"type": "http", "headers": [(b"x-inertia", b"false")]}
116
+
117
+ assert is_inertia_request(scope) is True
118
+
119
+ async def test_a_non_true_value_still_gets_the_safety_headers(self) -> None:
120
+ """A route that mirrors upstream's own (presence-only) detection.
121
+
122
+ Unlike ``cache_client``'s app, this handler does not call
123
+ ``is_inertia_request`` itself — it reproduces upstream's check
124
+ directly, so the two are genuinely independent here.
125
+ """
126
+
127
+ async def page(request):
128
+ if "x-inertia" in request.headers:
129
+ response = JSONResponse({"component": "Home", "props": {}})
130
+ else:
131
+ response = HTMLResponse("<!DOCTYPE html><title>Page</title>")
132
+ response.headers["Cache-Control"] = _PUBLIC_CACHE
133
+ response.headers["ETag"] = _SHARED_ETAG
134
+ return response
135
+
136
+ app = Starlette(routes=[Route("/p/home", page)])
137
+ app.add_middleware(InertiaCacheMiddleware)
138
+ transport = httpx.ASGITransport(app=app)
139
+
140
+ async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
141
+ resp = await client.get("/p/home", headers={"X-Inertia": "false"})
142
+
143
+ assert resp.headers["content-type"].startswith("application/json")
144
+ assert resp.headers["cache-control"] == PAYLOAD_CACHE_CONTROL
145
+ assert "etag" not in resp.headers
146
+
147
+
148
+ class TestDocumentResponse:
149
+ async def test_document_varies_on_x_inertia(self, cache_client: httpx.AsyncClient) -> None:
150
+ """Otherwise a cached document can be served to a client-side visit."""
151
+ async with cache_client as client:
152
+ resp = await client.get("/p/home")
153
+
154
+ assert "x-inertia" in _vary_fields(resp)
155
+
156
+ async def test_document_keeps_the_caching_the_route_chose(
157
+ self, cache_client: httpx.AsyncClient
158
+ ) -> None:
159
+ """Public page content stays cacheable — that half was never the bug."""
160
+ async with cache_client as client:
161
+ resp = await client.get("/p/home")
162
+
163
+ assert resp.headers["cache-control"] == _PUBLIC_CACHE
164
+ assert resp.headers["etag"] == _SHARED_ETAG
165
+
166
+
167
+ class TestUnrelatedResponses:
168
+ async def test_static_assets_are_untouched(self, cache_client: httpx.AsyncClient) -> None:
169
+ """Adding Vary or dropping ETags here would cost every asset its 304."""
170
+ async with cache_client as client:
171
+ resp = await client.get("/static/app.css")
172
+
173
+ assert resp.headers["etag"] == _SHARED_ETAG
174
+ assert "x-inertia" not in _vary_fields(resp)
175
+
176
+
177
+ class TestAgainstTheRealApp:
178
+ async def test_a_rendered_view_never_returns_a_storable_payload(
179
+ self, authenticated_client: httpx.AsyncClient
180
+ ) -> None:
181
+ """End to end, through the whole middleware pipeline."""
182
+ resp = await authenticated_client.get("/dashboard/", headers=_INERTIA)
183
+
184
+ assert resp.status_code == _OK
185
+ assert resp.headers["content-type"].startswith("application/json")
186
+ assert resp.headers["cache-control"] == PAYLOAD_CACHE_CONTROL
187
+ assert "x-inertia" in _vary_fields(resp)
@@ -0,0 +1,83 @@
1
+ """Both Inertia render paths must encode props the same way.
2
+
3
+ Upstream configures ``InertiaConfig.json_encoder`` — which exists to run props
4
+ through FastAPI's ``jsonable_encoder`` — and then only applies it on the full
5
+ page load. A client-side visit builds a Starlette ``JSONResponse``, reaching
6
+ plain ``json.dumps``. Any prop the stdlib cannot encode therefore renders on a
7
+ reload and 500s when reached by clicking a link, which is a miserable bug to
8
+ read from a stack trace: the page "works", right up until it is navigated to.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ from pathlib import Path
15
+
16
+ import pytest
17
+ from simple_module_hosting._inertia_json import json_safe_inertia_dependency
18
+ from starlette.responses import JSONResponse
19
+
20
+ _OK = 200
21
+
22
+
23
+ class _FakeInertia:
24
+ """Stands in for the library's Inertia, with the two hooks the wrap uses."""
25
+
26
+ def __init__(self, page_data: dict) -> None:
27
+ self._page_data = page_data
28
+ self.rendered_by_stock_encoder = False
29
+
30
+ async def _get_page_data(self) -> dict:
31
+ return self._page_data
32
+
33
+ async def _render_json(self) -> JSONResponse:
34
+ # What upstream does: no encoder, so a rich value raises here.
35
+ self.rendered_by_stock_encoder = True
36
+ return JSONResponse(content=self._page_data)
37
+
38
+
39
+ def _dependency_for(page_data: dict):
40
+ inertia = _FakeInertia(page_data)
41
+ dependency = json_safe_inertia_dependency(lambda request, client=None: inertia)
42
+ return dependency(object(), None)
43
+
44
+
45
+ class TestJsonSafeRendering:
46
+ async def test_a_path_prop_would_break_the_stock_encoder(self) -> None:
47
+ """Establishes the bug this wrap exists for, so the fix can't drift."""
48
+ with pytest.raises(TypeError, match="PosixPath"):
49
+ json.dumps({"props": {"media_root": Path("var/media")}})
50
+
51
+ async def test_a_path_prop_renders(self) -> None:
52
+ inertia = _dependency_for({"props": {"media_root": Path("var/media")}})
53
+
54
+ response = await inertia._render_json()
55
+
56
+ assert response.status_code == _OK
57
+ assert json.loads(bytes(response.body))["props"]["media_root"] == "var/media"
58
+
59
+ async def test_the_wrap_replaces_the_stock_path(self) -> None:
60
+ """Not merely catching the error afterwards — the encoder is swapped."""
61
+ inertia = _dependency_for({"props": {}})
62
+
63
+ await inertia._render_json()
64
+
65
+ assert inertia.rendered_by_stock_encoder is False
66
+
67
+ async def test_inertia_headers_are_preserved(self) -> None:
68
+ inertia = _dependency_for({"props": {}})
69
+
70
+ response = await inertia._render_json()
71
+
72
+ assert response.headers["x-inertia"] == "true"
73
+
74
+ async def test_an_unfamiliar_instance_is_left_alone(self) -> None:
75
+ """An upstream rename must not take the boot down with it."""
76
+
77
+ class _Unfamiliar:
78
+ pass
79
+
80
+ original = _Unfamiliar()
81
+ wrapped = json_safe_inertia_dependency(lambda request, client=None: original)
82
+
83
+ assert wrapped(object(), None) is original
@@ -4,7 +4,13 @@ CLAUDE.md spells out the pipeline:
4
4
 
5
5
  CorrelationId → RequestLogging → GZip → Security → Session → <module>
6
6
  → Tenant (opt-in) → Locale → InertiaLayoutData
7
- → CommitBeforeResponse → app
7
+ → InertiaCache → CommitBeforeResponse → app
8
+
9
+ InertiaCache sits directly inside InertiaLayoutData, the middleware that puts
10
+ this user's auth block, permissions and menus into every Inertia payload. Being
11
+ inside it means its send-wrapper sees the response before anything else can
12
+ read the headers, and pairing the two keeps "what makes the payload per-user"
13
+ and "what stops the payload being cached" from drifting apart.
8
14
 
9
15
  Tenant/Locale must see ``request.state.user`` set by AuthMiddleware so
10
16
  DB queries get filtered correctly; CorrelationId must wrap everything so
@@ -45,6 +51,7 @@ _EXPECTED_MULTI_TENANT = (
45
51
  "TenantMiddleware",
46
52
  "LocaleMiddleware",
47
53
  "InertiaLayoutDataMiddleware",
54
+ "InertiaCacheMiddleware",
48
55
  "CommitBeforeResponseMiddleware",
49
56
  )
50
57
 
@@ -58,6 +65,7 @@ _EXPECTED_SINGLE_TENANT = (
58
65
  "AuthMiddleware",
59
66
  "LocaleMiddleware",
60
67
  "InertiaLayoutDataMiddleware",
68
+ "InertiaCacheMiddleware",
61
69
  "CommitBeforeResponseMiddleware",
62
70
  )
63
71