simple-module-hosting 0.0.22__tar.gz → 0.0.25__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.22 → simple_module_hosting-0.0.25}/PKG-INFO +6 -5
- {simple_module_hosting-0.0.22 → simple_module_hosting-0.0.25}/README.md +3 -2
- {simple_module_hosting-0.0.22 → simple_module_hosting-0.0.25}/pyproject.toml +3 -3
- {simple_module_hosting-0.0.22 → simple_module_hosting-0.0.25}/simple_module_hosting/_error_handlers.py +7 -0
- {simple_module_hosting-0.0.22 → simple_module_hosting-0.0.25}/simple_module_hosting/_phase_helpers.py +22 -24
- {simple_module_hosting-0.0.22 → simple_module_hosting-0.0.25}/simple_module_hosting/app_builder.py +3 -4
- {simple_module_hosting-0.0.22 → simple_module_hosting-0.0.25}/simple_module_hosting/bootstrap_settings.py +25 -1
- simple_module_hosting-0.0.25/simple_module_hosting/static_files.py +102 -0
- simple_module_hosting-0.0.25/tests/test_error_page_shared_props.py +67 -0
- simple_module_hosting-0.0.25/tests/test_menu_urls_are_canonical.py +65 -0
- {simple_module_hosting-0.0.22 → simple_module_hosting-0.0.25}/tests/test_middleware_order.py +7 -2
- simple_module_hosting-0.0.25/tests/test_precompressed_static.py +142 -0
- simple_module_hosting-0.0.25/tests/test_proxy_headers.py +119 -0
- simple_module_hosting-0.0.25/tests/test_response_compression.py +88 -0
- {simple_module_hosting-0.0.22 → simple_module_hosting-0.0.25}/.gitignore +0 -0
- {simple_module_hosting-0.0.22 → simple_module_hosting-0.0.25}/LICENSE +0 -0
- {simple_module_hosting-0.0.22 → simple_module_hosting-0.0.25}/simple_module_hosting/__init__.py +0 -0
- {simple_module_hosting-0.0.22 → simple_module_hosting-0.0.25}/simple_module_hosting/__main__.py +0 -0
- {simple_module_hosting-0.0.22 → simple_module_hosting-0.0.25}/simple_module_hosting/_host_services.py +0 -0
- {simple_module_hosting-0.0.22 → simple_module_hosting-0.0.25}/simple_module_hosting/_hydrate_step.py +0 -0
- {simple_module_hosting-0.0.22 → simple_module_hosting-0.0.25}/simple_module_hosting/_inertia_setup.py +0 -0
- {simple_module_hosting-0.0.22 → simple_module_hosting-0.0.25}/simple_module_hosting/_inertia_shared.py +0 -0
- {simple_module_hosting-0.0.22 → simple_module_hosting-0.0.25}/simple_module_hosting/_observability.py +0 -0
- {simple_module_hosting-0.0.22 → simple_module_hosting-0.0.25}/simple_module_hosting/health.py +0 -0
- {simple_module_hosting-0.0.22 → simple_module_hosting-0.0.25}/simple_module_hosting/host_cli.py +0 -0
- {simple_module_hosting-0.0.22 → simple_module_hosting-0.0.25}/simple_module_hosting/host_settings.py +0 -0
- {simple_module_hosting-0.0.22 → simple_module_hosting-0.0.25}/simple_module_hosting/i18n_deps.py +0 -0
- {simple_module_hosting-0.0.22 → simple_module_hosting-0.0.25}/simple_module_hosting/i18n_manifest.py +0 -0
- {simple_module_hosting-0.0.22 → simple_module_hosting-0.0.25}/simple_module_hosting/i18n_middleware.py +0 -0
- {simple_module_hosting-0.0.22 → simple_module_hosting-0.0.25}/simple_module_hosting/inertia_deps.py +0 -0
- {simple_module_hosting-0.0.22 → simple_module_hosting-0.0.25}/simple_module_hosting/inertia_utils.py +0 -0
- {simple_module_hosting-0.0.22 → simple_module_hosting-0.0.25}/simple_module_hosting/logging.py +0 -0
- {simple_module_hosting-0.0.22 → simple_module_hosting-0.0.25}/simple_module_hosting/manifest.py +0 -0
- {simple_module_hosting-0.0.22 → simple_module_hosting-0.0.25}/simple_module_hosting/middleware.py +0 -0
- {simple_module_hosting-0.0.22 → simple_module_hosting-0.0.25}/simple_module_hosting/migrations.py +0 -0
- {simple_module_hosting-0.0.22 → simple_module_hosting-0.0.25}/simple_module_hosting/permissions.py +0 -0
- {simple_module_hosting-0.0.22 → simple_module_hosting-0.0.25}/simple_module_hosting/py.typed +0 -0
- {simple_module_hosting-0.0.22 → simple_module_hosting-0.0.25}/simple_module_hosting/redirects.py +0 -0
- {simple_module_hosting-0.0.22 → simple_module_hosting-0.0.25}/simple_module_hosting/settings.py +0 -0
- {simple_module_hosting-0.0.22 → simple_module_hosting-0.0.25}/simple_module_hosting/shared_props.py +0 -0
- {simple_module_hosting-0.0.22 → simple_module_hosting-0.0.25}/tests/test_app.py +0 -0
- {simple_module_hosting-0.0.22 → simple_module_hosting-0.0.25}/tests/test_branding_head.py +0 -0
- {simple_module_hosting-0.0.22 → simple_module_hosting-0.0.25}/tests/test_check_migrations.py +0 -0
- {simple_module_hosting-0.0.22 → simple_module_hosting-0.0.25}/tests/test_health.py +0 -0
- {simple_module_hosting-0.0.22 → simple_module_hosting-0.0.25}/tests/test_host_cli.py +0 -0
- {simple_module_hosting-0.0.22 → simple_module_hosting-0.0.25}/tests/test_hosting_permissions.py +0 -0
- {simple_module_hosting-0.0.22 → simple_module_hosting-0.0.25}/tests/test_i18n_manifest.py +0 -0
- {simple_module_hosting-0.0.22 → simple_module_hosting-0.0.25}/tests/test_inertia_i18n_shared_props.py +0 -0
- {simple_module_hosting-0.0.22 → simple_module_hosting-0.0.25}/tests/test_inertia_manifest.py +0 -0
- {simple_module_hosting-0.0.22 → simple_module_hosting-0.0.25}/tests/test_inertia_shared_providers.py +0 -0
- {simple_module_hosting-0.0.22 → simple_module_hosting-0.0.25}/tests/test_lifespan_order.py +0 -0
- {simple_module_hosting-0.0.22 → simple_module_hosting-0.0.25}/tests/test_locale_middleware.py +0 -0
- {simple_module_hosting-0.0.22 → simple_module_hosting-0.0.25}/tests/test_logging.py +0 -0
- {simple_module_hosting-0.0.22 → simple_module_hosting-0.0.25}/tests/test_manifest.py +0 -0
- {simple_module_hosting-0.0.22 → simple_module_hosting-0.0.25}/tests/test_redirects.py +0 -0
- {simple_module_hosting-0.0.22 → simple_module_hosting-0.0.25}/tests/test_session_cookie_security.py +0 -0
- {simple_module_hosting-0.0.22 → simple_module_hosting-0.0.25}/tests/test_settings_i18n.py +0 -0
- {simple_module_hosting-0.0.22 → simple_module_hosting-0.0.25}/tests/test_settings_secrets.py +0 -0
- {simple_module_hosting-0.0.22 → simple_module_hosting-0.0.25}/tests/test_static_caching.py +0 -0
- {simple_module_hosting-0.0.22 → simple_module_hosting-0.0.25}/tests/test_strict_discovery_wiring.py +0 -0
- {simple_module_hosting-0.0.22 → simple_module_hosting-0.0.25}/tests/test_tenant_middleware.py +0 -0
- {simple_module_hosting-0.0.22 → simple_module_hosting-0.0.25}/tests/test_translator_dep.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: simple_module_hosting
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.25
|
|
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
|
|
@@ -26,8 +26,8 @@ Requires-Dist: fastapi-inertia>=1.0
|
|
|
26
26
|
Requires-Dist: fastapi>=0.115
|
|
27
27
|
Requires-Dist: httpx>=0.27
|
|
28
28
|
Requires-Dist: jinja2>=3.1
|
|
29
|
-
Requires-Dist: simple-module-core==0.0.
|
|
30
|
-
Requires-Dist: simple-module-db==0.0.
|
|
29
|
+
Requires-Dist: simple-module-core==0.0.25
|
|
30
|
+
Requires-Dist: simple-module-db==0.0.25
|
|
31
31
|
Requires-Dist: starlette>=0.44
|
|
32
32
|
Requires-Dist: tomlkit>=0.13
|
|
33
33
|
Requires-Dist: uvicorn[standard]>=0.34
|
|
@@ -64,11 +64,12 @@ Minimal `main.py`:
|
|
|
64
64
|
from simple_module_hosting import create_app
|
|
65
65
|
from simple_module_hosting.settings import Settings
|
|
66
66
|
|
|
67
|
-
settings = Settings()
|
|
68
|
-
app = create_app(settings)
|
|
67
|
+
settings = Settings() # reads SM_* env vars
|
|
68
|
+
app = create_app(settings) # discovers + registers every installed module
|
|
69
69
|
|
|
70
70
|
if __name__ == "__main__":
|
|
71
71
|
import uvicorn
|
|
72
|
+
|
|
72
73
|
uvicorn.run(app, host="0.0.0.0", port=8000)
|
|
73
74
|
```
|
|
74
75
|
|
|
@@ -29,11 +29,12 @@ Minimal `main.py`:
|
|
|
29
29
|
from simple_module_hosting import create_app
|
|
30
30
|
from simple_module_hosting.settings import Settings
|
|
31
31
|
|
|
32
|
-
settings = Settings()
|
|
33
|
-
app = create_app(settings)
|
|
32
|
+
settings = Settings() # reads SM_* env vars
|
|
33
|
+
app = create_app(settings) # discovers + registers every installed module
|
|
34
34
|
|
|
35
35
|
if __name__ == "__main__":
|
|
36
36
|
import uvicorn
|
|
37
|
+
|
|
37
38
|
uvicorn.run(app, host="0.0.0.0", port=8000)
|
|
38
39
|
```
|
|
39
40
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "simple_module_hosting"
|
|
3
|
-
version = "0.0.
|
|
3
|
+
version = "0.0.25"
|
|
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"
|
|
@@ -26,8 +26,8 @@ dependencies = [
|
|
|
26
26
|
"fastapi-inertia>=1.0",
|
|
27
27
|
"httpx>=0.27",
|
|
28
28
|
"jinja2>=3.1",
|
|
29
|
-
"simple_module_core==0.0.
|
|
30
|
-
"simple_module_db==0.0.
|
|
29
|
+
"simple_module_core==0.0.25",
|
|
30
|
+
"simple_module_db==0.0.25",
|
|
31
31
|
"starlette>=0.44",
|
|
32
32
|
"tomlkit>=0.13",
|
|
33
33
|
"uvicorn[standard]>=0.34",
|
|
@@ -27,6 +27,13 @@ async def render_error_page(request: Request, status_code: int, message: str) ->
|
|
|
27
27
|
config: InertiaConfig = request.app.state.sm.inertia_config
|
|
28
28
|
try:
|
|
29
29
|
inertia = Inertia(request, config)
|
|
30
|
+
# This builds its own Inertia instead of going through get_inertia, so
|
|
31
|
+
# the share step has to be repeated here. Without it the error page
|
|
32
|
+
# renders raw translation keys (host.error.not_found_title) and loses
|
|
33
|
+
# auth/menus, so a signed-in user's 404 has no layout.
|
|
34
|
+
shared = getattr(request.state, "inertia_shared", None)
|
|
35
|
+
if shared:
|
|
36
|
+
inertia.share(**shared)
|
|
30
37
|
response = await inertia.render("Error", {"status": status_code, "message": message})
|
|
31
38
|
response.status_code = status_code
|
|
32
39
|
return response
|
|
@@ -22,9 +22,9 @@ from inertia import (
|
|
|
22
22
|
from simple_module_core.diagnostics import Diagnostic, DiagnosticLevel
|
|
23
23
|
from simple_module_core.exceptions import NotFoundError
|
|
24
24
|
from starlette.exceptions import HTTPException
|
|
25
|
+
from starlette.middleware.gzip import GZipMiddleware
|
|
25
26
|
from starlette.middleware.sessions import SessionMiddleware
|
|
26
|
-
from
|
|
27
|
-
from starlette.types import Scope
|
|
27
|
+
from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware
|
|
28
28
|
|
|
29
29
|
from simple_module_hosting._error_handlers import (
|
|
30
30
|
http_exception_handler,
|
|
@@ -41,6 +41,7 @@ from simple_module_hosting.middleware import (
|
|
|
41
41
|
TenantMiddleware,
|
|
42
42
|
)
|
|
43
43
|
from simple_module_hosting.settings import Settings
|
|
44
|
+
from simple_module_hosting.static_files import PrecompressedStaticFiles
|
|
44
45
|
|
|
45
46
|
if TYPE_CHECKING:
|
|
46
47
|
from simple_module_core.menu import MenuRegistry
|
|
@@ -48,27 +49,11 @@ if TYPE_CHECKING:
|
|
|
48
49
|
|
|
49
50
|
logger = logging.getLogger(__name__)
|
|
50
51
|
|
|
51
|
-
|
|
52
|
+
# Below this, gzip framing costs more than it saves. Starlette's own default.
|
|
53
|
+
COMPRESSION_MIN_BYTES = 500
|
|
52
54
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
"""StaticFiles that marks Vite's content-hashed build assets immutable.
|
|
56
|
-
|
|
57
|
-
Vite emits files under ``dist/assets/`` with a content hash in the filename
|
|
58
|
-
(e.g. ``main-3YbShAJ4.js``), so the bytes for a given URL never change —
|
|
59
|
-
browsers can cache them indefinitely and skip even the revalidation
|
|
60
|
-
round-trip. The default StaticFiles only sets ETag/Last-Modified, forcing a
|
|
61
|
-
conditional GET per asset on every visit. Non-hashed paths (the manifest,
|
|
62
|
-
etc.) keep the default behaviour.
|
|
63
|
-
"""
|
|
64
|
-
|
|
65
|
-
async def get_response(self, path: str, scope: Scope) -> Response:
|
|
66
|
-
response = await super().get_response(path, scope)
|
|
67
|
-
# StaticFiles hands us an OS-separator path (backslashes on Windows), so
|
|
68
|
-
# normalize before matching the forward-slash asset prefix.
|
|
69
|
-
if response.status_code == 200 and path.replace("\\", "/").startswith("dist/assets/"):
|
|
70
|
-
response.headers["Cache-Control"] = _IMMUTABLE_CACHE_CONTROL
|
|
71
|
-
return response
|
|
55
|
+
# Re-exported for back-compat: static-file serving now lives in static_files.
|
|
56
|
+
ImmutableStaticFiles = PrecompressedStaticFiles
|
|
72
57
|
|
|
73
58
|
|
|
74
59
|
def register_exception_handlers(app: FastAPI, modules: list) -> None:
|
|
@@ -95,8 +80,8 @@ def install_middleware(
|
|
|
95
80
|
"""Install the full middleware pipeline.
|
|
96
81
|
|
|
97
82
|
Order matters: last added = first executed. Execution order:
|
|
98
|
-
|
|
99
|
-
→ [module] → (Tenant, if multi_tenant) → Locale → Inertia.
|
|
83
|
+
(ProxyHeaders, if trusted_proxy) → CorrelationId → RequestLogging
|
|
84
|
+
→ Security → Session → [module] → (Tenant, if multi_tenant) → Locale → Inertia.
|
|
100
85
|
"""
|
|
101
86
|
app.add_middleware(
|
|
102
87
|
InertiaLayoutDataMiddleware,
|
|
@@ -125,8 +110,21 @@ def install_middleware(
|
|
|
125
110
|
)
|
|
126
111
|
else:
|
|
127
112
|
app.add_middleware(SecurityHeadersMiddleware)
|
|
113
|
+
# Compress response bodies. Added here so it sits inside CorrelationId and
|
|
114
|
+
# RequestLogging (which set headers and read request state) but outside
|
|
115
|
+
# everything that produces a body — including the /static mount, where it
|
|
116
|
+
# matters most: the built CSS is ~139 KB raw and ~21 KB gzipped, and the
|
|
117
|
+
# JS bundle compresses about 3x. Uncompressed assets dominated cold page
|
|
118
|
+
# load, several times larger than anything on the server request path.
|
|
119
|
+
app.add_middleware(GZipMiddleware, minimum_size=COMPRESSION_MIN_BYTES)
|
|
128
120
|
app.add_middleware(RequestLoggingMiddleware)
|
|
129
121
|
app.add_middleware(CorrelationIdMiddleware)
|
|
122
|
+
# Outermost: rewrite scheme/client from X-Forwarded-* before anything else
|
|
123
|
+
# reads them, so request logs see the real client IP and Inertia's absolute
|
|
124
|
+
# page url carries the proxy-terminated scheme (GH #223). Gated on an
|
|
125
|
+
# explicit trust setting — never trust forwarded headers by default.
|
|
126
|
+
if settings.trusted_proxy:
|
|
127
|
+
app.add_middleware(ProxyHeadersMiddleware, trusted_hosts=settings.trusted_proxy)
|
|
130
128
|
|
|
131
129
|
|
|
132
130
|
def attach_public_routes(app: FastAPI, settings: Settings, registry) -> None:
|
{simple_module_hosting-0.0.22 → simple_module_hosting-0.0.25}/simple_module_hosting/app_builder.py
RENAMED
|
@@ -25,7 +25,6 @@ from simple_module_db.session import init_db
|
|
|
25
25
|
from simple_module_hosting._host_services import _HostServices
|
|
26
26
|
from simple_module_hosting._inertia_setup import setup_inertia
|
|
27
27
|
from simple_module_hosting._phase_helpers import (
|
|
28
|
-
ImmutableStaticFiles,
|
|
29
28
|
attach_public_routes,
|
|
30
29
|
check_settings_registration,
|
|
31
30
|
install_middleware,
|
|
@@ -38,6 +37,7 @@ from simple_module_hosting.host_settings import HostSettings
|
|
|
38
37
|
from simple_module_hosting.i18n_manifest import build_i18n_registry, emit_frontend_types
|
|
39
38
|
from simple_module_hosting.migrations import check_migrations
|
|
40
39
|
from simple_module_hosting.settings import Settings
|
|
40
|
+
from simple_module_hosting.static_files import PrecompressedStaticFiles
|
|
41
41
|
|
|
42
42
|
logger = logging.getLogger(__name__)
|
|
43
43
|
|
|
@@ -277,9 +277,8 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
|
|
277
277
|
|
|
278
278
|
static_dir = _PROJECT_ROOT / "host" / _STATIC_DIR_NAME
|
|
279
279
|
if static_dir.is_dir():
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
)
|
|
280
|
+
static = PrecompressedStaticFiles(directory=static_dir)
|
|
281
|
+
app.mount(_STATIC_MOUNT_PATH, static, name=_STATIC_DIR_NAME)
|
|
283
282
|
|
|
284
283
|
mount_module_static_dirs(app, modules)
|
|
285
284
|
|
|
@@ -9,7 +9,7 @@ from __future__ import annotations
|
|
|
9
9
|
|
|
10
10
|
from typing import Literal
|
|
11
11
|
|
|
12
|
-
from pydantic import model_validator
|
|
12
|
+
from pydantic import field_validator, model_validator
|
|
13
13
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
14
14
|
from simple_module_core.environments import NON_PROD_ENVIRONMENTS
|
|
15
15
|
|
|
@@ -32,6 +32,15 @@ class BootstrapSettings(BaseSettings):
|
|
|
32
32
|
vite_dev_url: str = "http://localhost:5050"
|
|
33
33
|
debug: bool = False
|
|
34
34
|
|
|
35
|
+
# Trust the X-Forwarded-* headers from a fronting reverse proxy. Unset by
|
|
36
|
+
# default (no proxy trusted). Set to ``*`` to trust any peer (correct when
|
|
37
|
+
# the container is only reachable through a single proxy), or a comma-
|
|
38
|
+
# separated list of proxy IPs / CIDRs. Drives uvicorn's
|
|
39
|
+
# ProxyHeadersMiddleware so request.url.scheme reflects X-Forwarded-Proto
|
|
40
|
+
# behind a TLS-terminating proxy — without it Inertia's pushState throws a
|
|
41
|
+
# cross-scheme SecurityError and login breaks (GH #223).
|
|
42
|
+
trusted_proxy: str | None = None
|
|
43
|
+
|
|
35
44
|
log_level: str = "INFO"
|
|
36
45
|
log_format: Literal["json", "text"] = "json"
|
|
37
46
|
|
|
@@ -46,6 +55,21 @@ class BootstrapSettings(BaseSettings):
|
|
|
46
55
|
``register_public_routes`` hook, which is method-aware.
|
|
47
56
|
"""
|
|
48
57
|
|
|
58
|
+
@field_validator("trusted_proxy", mode="after")
|
|
59
|
+
@classmethod
|
|
60
|
+
def _normalize_trusted_proxy(cls, value: str | None) -> str | None:
|
|
61
|
+
"""Strip surrounding whitespace; treat blank as unset.
|
|
62
|
+
|
|
63
|
+
Guards against a stray space silently defeating the feature: uvicorn
|
|
64
|
+
decides ``*``-trust by comparing the *raw* string to ``"*"`` before it
|
|
65
|
+
strips, so ``"* "`` would be parsed as a literal host that matches no
|
|
66
|
+
client — re-introducing GH #223 with no error. Blank → ``None`` so the
|
|
67
|
+
middleware isn't installed at all.
|
|
68
|
+
"""
|
|
69
|
+
if value is None:
|
|
70
|
+
return None
|
|
71
|
+
return value.strip() or None
|
|
72
|
+
|
|
49
73
|
@property
|
|
50
74
|
def is_development(self) -> bool:
|
|
51
75
|
return self.environment == "development"
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Static-file serving: immutable caching and pre-compressed variants.
|
|
2
|
+
|
|
3
|
+
Split out of ``_phase_helpers`` — static delivery is its own responsibility,
|
|
4
|
+
and that module was near the repo's 300-line cap.
|
|
5
|
+
|
|
6
|
+
Two optimizations live here, both aimed at cold page load:
|
|
7
|
+
|
|
8
|
+
* **Immutable caching.** Vite emits content-hashed filenames, so the bytes for
|
|
9
|
+
a given URL never change and the browser can skip even the revalidation
|
|
10
|
+
round-trip.
|
|
11
|
+
* **Pre-compressed variants.** ``GZipMiddleware`` would otherwise re-compress
|
|
12
|
+
the same immutable bundle on every request, using a fast (and therefore
|
|
13
|
+
worse) compression level. Files compressed once at build time can afford the
|
|
14
|
+
maximum level, and brotli is ~14% smaller than gzip on this bundle.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from mimetypes import guess_type
|
|
20
|
+
|
|
21
|
+
from fastapi.staticfiles import StaticFiles
|
|
22
|
+
from starlette.datastructures import Headers
|
|
23
|
+
from starlette.exceptions import HTTPException
|
|
24
|
+
from starlette.responses import Response
|
|
25
|
+
from starlette.types import Scope
|
|
26
|
+
|
|
27
|
+
__all__ = ["IMMUTABLE_CACHE_CONTROL", "PrecompressedStaticFiles"]
|
|
28
|
+
|
|
29
|
+
IMMUTABLE_CACHE_CONTROL = "public, max-age=31536000, immutable"
|
|
30
|
+
|
|
31
|
+
_HASHED_ASSET_PREFIX = "dist/assets/"
|
|
32
|
+
_ACCEPT_ENCODING = "accept-encoding"
|
|
33
|
+
_CONTENT_ENCODING = "Content-Encoding"
|
|
34
|
+
_CONTENT_TYPE = "content-type"
|
|
35
|
+
_VARY = "Vary"
|
|
36
|
+
_ACCEPT_ENCODING_HEADER = "Accept-Encoding"
|
|
37
|
+
_OK = 200
|
|
38
|
+
|
|
39
|
+
# (encoding token, filename suffix), best first. Brotli wins where offered:
|
|
40
|
+
# measured 248.5 KB vs 287.6 KB gzip-9 across this bundle.
|
|
41
|
+
_VARIANTS: tuple[tuple[str, str], ...] = (("br", ".br"), ("gzip", ".gz"))
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _accepted_encodings(scope: Scope) -> set[str]:
|
|
45
|
+
"""Encoding tokens the client advertises.
|
|
46
|
+
|
|
47
|
+
Deliberately ignores q-values. Any client sending ``br;q=0`` to actively
|
|
48
|
+
refuse brotli is vanishingly rare, and getting it wrong only costs a few
|
|
49
|
+
KB — whereas parsing it wrong could serve a body the client cannot read.
|
|
50
|
+
Tokens are matched exactly, so a ``q=0`` entry still reads as "accepted";
|
|
51
|
+
that is the conservative direction only because every real client that
|
|
52
|
+
lists an encoding can decode it.
|
|
53
|
+
"""
|
|
54
|
+
raw = Headers(scope=scope).get(_ACCEPT_ENCODING, "")
|
|
55
|
+
return {part.split(";")[0].strip().lower() for part in raw.split(",") if part.strip()}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class PrecompressedStaticFiles(StaticFiles):
|
|
59
|
+
"""Serve ``.br``/``.gz`` siblings when present, and mark hashed assets immutable.
|
|
60
|
+
|
|
61
|
+
A request for ``app.js`` is answered with ``app.js.br`` when the client
|
|
62
|
+
accepts brotli and that file exists, falling back to ``.gz`` and then to the
|
|
63
|
+
original. The response keeps the *original* file's Content-Type — serving
|
|
64
|
+
``app.js.br`` directly would have Starlette type it from the ``.br``
|
|
65
|
+
extension, and a browser will refuse to execute a script typed as
|
|
66
|
+
``application/octet-stream``.
|
|
67
|
+
|
|
68
|
+
``Vary: Accept-Encoding`` is always set on a negotiated response so shared
|
|
69
|
+
caches cannot hand a compressed body to a client that never asked for one.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
async def get_response(self, path: str, scope: Scope) -> Response:
|
|
73
|
+
response = await self._get_negotiated_response(path, scope)
|
|
74
|
+
# StaticFiles hands us an OS-separator path (backslashes on Windows), so
|
|
75
|
+
# normalize before matching the forward-slash asset prefix.
|
|
76
|
+
if response.status_code == _OK and path.replace("\\", "/").startswith(_HASHED_ASSET_PREFIX):
|
|
77
|
+
response.headers["Cache-Control"] = IMMUTABLE_CACHE_CONTROL
|
|
78
|
+
return response
|
|
79
|
+
|
|
80
|
+
async def _get_negotiated_response(self, path: str, scope: Scope) -> Response:
|
|
81
|
+
accepted = _accepted_encodings(scope)
|
|
82
|
+
for encoding, suffix in _VARIANTS:
|
|
83
|
+
if encoding not in accepted:
|
|
84
|
+
continue
|
|
85
|
+
# StaticFiles signals "no such file" by *raising* HTTPException(404),
|
|
86
|
+
# not by returning a 404 response — so a missing variant has to be
|
|
87
|
+
# caught here, not detected from a status code.
|
|
88
|
+
try:
|
|
89
|
+
variant = await super().get_response(path + suffix, scope)
|
|
90
|
+
except HTTPException:
|
|
91
|
+
continue
|
|
92
|
+
if variant.status_code != _OK:
|
|
93
|
+
continue
|
|
94
|
+
# Type by the original filename, not the variant's extension: a
|
|
95
|
+
# browser refuses to execute a script typed application/octet-stream.
|
|
96
|
+
content_type = guess_type(path)[0]
|
|
97
|
+
if content_type:
|
|
98
|
+
variant.headers[_CONTENT_TYPE] = content_type
|
|
99
|
+
variant.headers[_CONTENT_ENCODING] = encoding
|
|
100
|
+
variant.headers[_VARY] = _ACCEPT_ENCODING_HEADER
|
|
101
|
+
return variant
|
|
102
|
+
return await super().get_response(path, scope)
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""The Inertia error page must carry the same shared props as any other page.
|
|
2
|
+
|
|
3
|
+
``render_error_page`` builds its own ``Inertia`` instance instead of going
|
|
4
|
+
through ``get_inertia``, so it skipped the ``inertia.share(**shared)`` step.
|
|
5
|
+
The consequence was visible: a 404 rendered its copy as raw translation keys
|
|
6
|
+
— ``host.error.not_found_title``, ``host.error.go_home`` — because the page
|
|
7
|
+
received no ``i18n`` block at all. It also lost ``auth`` and ``menus``, so the
|
|
8
|
+
error page could not render the layout a signed-in user expects.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import html
|
|
14
|
+
import json
|
|
15
|
+
import re
|
|
16
|
+
|
|
17
|
+
import httpx
|
|
18
|
+
import pytest
|
|
19
|
+
|
|
20
|
+
_NOT_FOUND = 404
|
|
21
|
+
_MISSING_PATH = "/definitely/not/a/real/route"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _inertia_page(body: str) -> dict:
|
|
25
|
+
"""Pull the JSON blob Inertia embeds in the server-rendered document."""
|
|
26
|
+
match = re.search(r'data-page="([^"]+)"', body) or re.search(r"data-page='([^']+)'", body)
|
|
27
|
+
if match is None:
|
|
28
|
+
pytest.fail("no data-page attribute found — the error page did not render via Inertia")
|
|
29
|
+
return json.loads(html.unescape(match.group(1)))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class TestErrorPageSharedProps:
|
|
33
|
+
async def test_error_page_renders_via_inertia(
|
|
34
|
+
self, authenticated_client: httpx.AsyncClient
|
|
35
|
+
) -> None:
|
|
36
|
+
resp = await authenticated_client.get(_MISSING_PATH)
|
|
37
|
+
assert resp.status_code == _NOT_FOUND
|
|
38
|
+
assert _inertia_page(resp.text)["component"] == "Error"
|
|
39
|
+
|
|
40
|
+
async def test_error_page_carries_i18n_messages(
|
|
41
|
+
self, authenticated_client: httpx.AsyncClient
|
|
42
|
+
) -> None:
|
|
43
|
+
"""Without this the page prints raw keys like host.error.not_found_title."""
|
|
44
|
+
resp = await authenticated_client.get(_MISSING_PATH)
|
|
45
|
+
props = _inertia_page(resp.text)["props"]
|
|
46
|
+
assert "i18n" in props, f"error page has no i18n block; props={sorted(props)}"
|
|
47
|
+
assert props["i18n"].get("messages"), "i18n block present but messages are empty"
|
|
48
|
+
|
|
49
|
+
async def test_error_page_carries_auth_and_menus(
|
|
50
|
+
self, authenticated_client: httpx.AsyncClient
|
|
51
|
+
) -> None:
|
|
52
|
+
"""The layout needs these to render for a signed-in user."""
|
|
53
|
+
props = _inertia_page((await authenticated_client.get(_MISSING_PATH)).text)["props"]
|
|
54
|
+
assert "auth" in props, f"error page has no auth block; props={sorted(props)}"
|
|
55
|
+
assert "menus" in props, f"error page has no menus block; props={sorted(props)}"
|
|
56
|
+
|
|
57
|
+
async def test_error_page_keeps_its_own_props(
|
|
58
|
+
self, authenticated_client: httpx.AsyncClient
|
|
59
|
+
) -> None:
|
|
60
|
+
"""Sharing must not clobber status/message."""
|
|
61
|
+
props = _inertia_page((await authenticated_client.get(_MISSING_PATH)).text)["props"]
|
|
62
|
+
assert props["status"] == _NOT_FOUND
|
|
63
|
+
|
|
64
|
+
async def test_anonymous_error_page_still_renders(self, client: httpx.AsyncClient) -> None:
|
|
65
|
+
"""An unauthenticated 404 must not blow up on missing shared state."""
|
|
66
|
+
resp = await client.get("/health/definitely-not-real")
|
|
67
|
+
assert resp.status_code >= _NOT_FOUND
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Every sidebar link must hit its route directly, with no redirect.
|
|
2
|
+
|
|
3
|
+
A menu item pointing at ``/audit_log`` when the route is registered at
|
|
4
|
+
``/audit_log/`` still works — Starlette 307s to the canonical path and the
|
|
5
|
+
client follows. But it costs a full extra round trip on *every* navigation to
|
|
6
|
+
that page. On localhost that's ~8 ms and invisible; on a 40 ms-latency link it
|
|
7
|
+
roughly doubles the navigation, and it is worse on mobile.
|
|
8
|
+
|
|
9
|
+
Five of the eight sidebar links had this mismatch when this test was written.
|
|
10
|
+
It is a whole class of bug that no functional test catches, because everything
|
|
11
|
+
still renders correctly — just slower.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
import pytest
|
|
18
|
+
from simple_module_hosting.middleware import InertiaLayoutDataMiddleware
|
|
19
|
+
|
|
20
|
+
_REDIRECT_CODES = {301, 302, 307, 308}
|
|
21
|
+
_NOT_FOUND = 404
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _menu_urls(app) -> list[str]:
|
|
25
|
+
"""Every internal menu URL registered by the installed modules."""
|
|
26
|
+
for mw in app.user_middleware:
|
|
27
|
+
if mw.cls is InertiaLayoutDataMiddleware:
|
|
28
|
+
registry = mw.kwargs["menu_registry"]
|
|
29
|
+
break
|
|
30
|
+
else: # pragma: no cover - the middleware is always installed
|
|
31
|
+
pytest.fail("InertiaLayoutDataMiddleware not installed")
|
|
32
|
+
|
|
33
|
+
return sorted(
|
|
34
|
+
{
|
|
35
|
+
item.url
|
|
36
|
+
for item in registry.all_items
|
|
37
|
+
if item.url.startswith("/") and item.method == "get"
|
|
38
|
+
}
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
async def test_menu_urls_do_not_redirect(app, authenticated_client: httpx.AsyncClient) -> None:
|
|
43
|
+
"""No sidebar link may cost a redirect hop."""
|
|
44
|
+
offenders: list[str] = []
|
|
45
|
+
for url in _menu_urls(app):
|
|
46
|
+
resp = await authenticated_client.get(url, headers={"X-Inertia": "true"})
|
|
47
|
+
if resp.status_code in _REDIRECT_CODES:
|
|
48
|
+
offenders.append(f"{url} -> {resp.status_code} {resp.headers.get('location', '')}")
|
|
49
|
+
|
|
50
|
+
assert not offenders, (
|
|
51
|
+
"These menu URLs redirect, costing an extra round trip on every "
|
|
52
|
+
"navigation. Point the menu item at the canonical path (usually adding "
|
|
53
|
+
"a trailing slash), or register the route without one:\n " + "\n ".join(offenders)
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
async def test_menu_urls_resolve(app, authenticated_client: httpx.AsyncClient) -> None:
|
|
58
|
+
"""A menu item pointing at a route that doesn't exist is a dead link."""
|
|
59
|
+
dead: list[str] = []
|
|
60
|
+
for url in _menu_urls(app):
|
|
61
|
+
resp = await authenticated_client.get(url, headers={"X-Inertia": "true"})
|
|
62
|
+
if resp.status_code == _NOT_FOUND:
|
|
63
|
+
dead.append(url)
|
|
64
|
+
|
|
65
|
+
assert not dead, f"menu URLs resolve to 404: {dead}"
|
{simple_module_hosting-0.0.22 → simple_module_hosting-0.0.25}/tests/test_middleware_order.py
RENAMED
|
@@ -2,12 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
CLAUDE.md spells out the pipeline:
|
|
4
4
|
|
|
5
|
-
CorrelationId → RequestLogging → Security → Session → <module>
|
|
5
|
+
CorrelationId → RequestLogging → GZip → Security → Session → <module>
|
|
6
6
|
→ Tenant (opt-in) → Locale → InertiaLayoutData → app
|
|
7
7
|
|
|
8
8
|
Tenant/Locale must see ``request.state.user`` set by AuthMiddleware so
|
|
9
9
|
DB queries get filtered correctly; CorrelationId must wrap everything so
|
|
10
|
-
every log line carries its id.
|
|
10
|
+
every log line carries its id. GZip sits inside the observability pair so
|
|
11
|
+
those still see every request, but outside everything that produces a body
|
|
12
|
+
— including the /static mount, which is where compression pays off most.
|
|
13
|
+
Order matters and a swap is the kind of
|
|
11
14
|
regression that breaks production without breaking any happy-path test.
|
|
12
15
|
``app.user_middleware`` lists middlewares in execution order (Starlette
|
|
13
16
|
LIFOs ``add_middleware`` calls, FastAPI surfaces them already reversed).
|
|
@@ -22,6 +25,7 @@ from simple_module_hosting.settings import Settings
|
|
|
22
25
|
_EXPECTED_MULTI_TENANT = (
|
|
23
26
|
"CorrelationIdMiddleware",
|
|
24
27
|
"RequestLoggingMiddleware",
|
|
28
|
+
"GZipMiddleware",
|
|
25
29
|
"SecurityHeadersMiddleware",
|
|
26
30
|
"SessionMiddleware",
|
|
27
31
|
"AuthMiddleware",
|
|
@@ -33,6 +37,7 @@ _EXPECTED_MULTI_TENANT = (
|
|
|
33
37
|
_EXPECTED_SINGLE_TENANT = (
|
|
34
38
|
"CorrelationIdMiddleware",
|
|
35
39
|
"RequestLoggingMiddleware",
|
|
40
|
+
"GZipMiddleware",
|
|
36
41
|
"SecurityHeadersMiddleware",
|
|
37
42
|
"SessionMiddleware",
|
|
38
43
|
"AuthMiddleware",
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Serving pre-compressed asset variants.
|
|
2
|
+
|
|
3
|
+
Compressing the same immutable, content-hashed bundle on every request is
|
|
4
|
+
wasted CPU, and on-the-fly compression has to use a fast (worse) level. Files
|
|
5
|
+
compressed once at build time can use the maximum level, and brotli is ~14%
|
|
6
|
+
smaller than gzip on this bundle.
|
|
7
|
+
|
|
8
|
+
The subtle part is the Content-Type: naively serving ``main.js.br`` makes
|
|
9
|
+
Starlette guess the type from the ``.br`` extension and the browser refuses to
|
|
10
|
+
execute it. These tests pin that behaviour.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import gzip
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
import brotli
|
|
19
|
+
import httpx
|
|
20
|
+
import pytest
|
|
21
|
+
from fastapi import FastAPI
|
|
22
|
+
from simple_module_hosting.static_files import PrecompressedStaticFiles
|
|
23
|
+
|
|
24
|
+
_JS = b"console.log('hello');" * 200
|
|
25
|
+
_OK = 200
|
|
26
|
+
_JS_TYPE = "text/javascript"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@pytest.fixture
|
|
30
|
+
def asset_dir(tmp_path: Path) -> Path:
|
|
31
|
+
(tmp_path / "app.js").write_bytes(_JS)
|
|
32
|
+
(tmp_path / "app.js.gz").write_bytes(gzip.compress(_JS, 9))
|
|
33
|
+
# A file with no pre-compressed sibling, to prove the fallback path.
|
|
34
|
+
(tmp_path / "plain.js").write_bytes(_JS)
|
|
35
|
+
return tmp_path
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@pytest.fixture
|
|
39
|
+
def client(asset_dir: Path) -> httpx.AsyncClient:
|
|
40
|
+
app = FastAPI()
|
|
41
|
+
app.mount("/static", PrecompressedStaticFiles(directory=asset_dir), name="static")
|
|
42
|
+
return httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://testserver")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class TestPrecompressedServing:
|
|
46
|
+
async def test_serves_gzip_sibling_when_accepted(self, client: httpx.AsyncClient) -> None:
|
|
47
|
+
resp = await client.get(
|
|
48
|
+
"/static/app.js", headers={"Accept-Encoding": "gzip"}, extensions={"trust_env": False}
|
|
49
|
+
)
|
|
50
|
+
assert resp.status_code == _OK
|
|
51
|
+
assert resp.headers["content-encoding"] == "gzip"
|
|
52
|
+
|
|
53
|
+
async def test_content_type_is_the_original_not_the_variant(
|
|
54
|
+
self, client: httpx.AsyncClient
|
|
55
|
+
) -> None:
|
|
56
|
+
"""The classic bug: .gz would otherwise be typed as octet-stream and
|
|
57
|
+
the browser would refuse to execute the script."""
|
|
58
|
+
resp = await client.get("/static/app.js", headers={"Accept-Encoding": "gzip"})
|
|
59
|
+
assert _JS_TYPE in resp.headers["content-type"]
|
|
60
|
+
|
|
61
|
+
async def test_body_decodes_to_the_original_bytes(self, client: httpx.AsyncClient) -> None:
|
|
62
|
+
resp = await client.get("/static/app.js", headers={"Accept-Encoding": "gzip"})
|
|
63
|
+
assert resp.content == _JS
|
|
64
|
+
|
|
65
|
+
async def test_vary_header_is_set(self, client: httpx.AsyncClient) -> None:
|
|
66
|
+
"""Without Vary, a shared cache could serve a compressed body to a
|
|
67
|
+
client that never asked for one."""
|
|
68
|
+
resp = await client.get("/static/app.js", headers={"Accept-Encoding": "gzip"})
|
|
69
|
+
assert "accept-encoding" in resp.headers.get("vary", "").lower()
|
|
70
|
+
|
|
71
|
+
async def test_identity_client_gets_uncompressed(self, client: httpx.AsyncClient) -> None:
|
|
72
|
+
resp = await client.get("/static/app.js", headers={"Accept-Encoding": "identity"})
|
|
73
|
+
assert resp.status_code == _OK
|
|
74
|
+
assert "content-encoding" not in resp.headers
|
|
75
|
+
assert resp.content == _JS
|
|
76
|
+
|
|
77
|
+
async def test_missing_sibling_falls_back_to_original(self, client: httpx.AsyncClient) -> None:
|
|
78
|
+
resp = await client.get("/static/plain.js", headers={"Accept-Encoding": "gzip, br"})
|
|
79
|
+
assert resp.status_code == _OK
|
|
80
|
+
assert "content-encoding" not in resp.headers
|
|
81
|
+
assert resp.content == _JS
|
|
82
|
+
|
|
83
|
+
async def test_unknown_file_still_404s(self, client: httpx.AsyncClient) -> None:
|
|
84
|
+
resp = await client.get("/static/nope.js", headers={"Accept-Encoding": "gzip"})
|
|
85
|
+
assert resp.status_code == 404
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class TestBrotliPreference:
|
|
89
|
+
async def test_brotli_preferred_over_gzip(self, tmp_path: Path) -> None:
|
|
90
|
+
"""Brotli is ~14% smaller on this bundle, so it wins when both exist."""
|
|
91
|
+
(tmp_path / "app.js").write_bytes(_JS)
|
|
92
|
+
(tmp_path / "app.js.gz").write_bytes(gzip.compress(_JS, 9))
|
|
93
|
+
(tmp_path / "app.js.br").write_bytes(brotli.compress(_JS))
|
|
94
|
+
|
|
95
|
+
app = FastAPI()
|
|
96
|
+
app.mount("/static", PrecompressedStaticFiles(directory=tmp_path), name="static")
|
|
97
|
+
async with httpx.AsyncClient(
|
|
98
|
+
transport=httpx.ASGITransport(app=app), base_url="http://testserver"
|
|
99
|
+
) as c:
|
|
100
|
+
resp = await c.get("/static/app.js", headers={"Accept-Encoding": "gzip, deflate, br"})
|
|
101
|
+
assert resp.headers["content-encoding"] == "br"
|
|
102
|
+
|
|
103
|
+
async def test_gzip_used_when_brotli_not_accepted(self, tmp_path: Path) -> None:
|
|
104
|
+
(tmp_path / "app.js").write_bytes(_JS)
|
|
105
|
+
(tmp_path / "app.js.gz").write_bytes(gzip.compress(_JS, 9))
|
|
106
|
+
(tmp_path / "app.js.br").write_bytes(brotli.compress(_JS))
|
|
107
|
+
|
|
108
|
+
app = FastAPI()
|
|
109
|
+
app.mount("/static", PrecompressedStaticFiles(directory=tmp_path), name="static")
|
|
110
|
+
async with httpx.AsyncClient(
|
|
111
|
+
transport=httpx.ASGITransport(app=app), base_url="http://testserver"
|
|
112
|
+
) as c:
|
|
113
|
+
resp = await c.get("/static/app.js", headers={"Accept-Encoding": "gzip"})
|
|
114
|
+
assert resp.headers["content-encoding"] == "gzip"
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class TestImmutableCachingPreserved:
|
|
118
|
+
"""The existing immutable-caching behaviour must survive the refactor."""
|
|
119
|
+
|
|
120
|
+
async def test_hashed_assets_keep_immutable_cache_control(self, tmp_path: Path) -> None:
|
|
121
|
+
assets = tmp_path / "dist" / "assets"
|
|
122
|
+
assets.mkdir(parents=True)
|
|
123
|
+
(assets / "main-abc123.js").write_bytes(_JS)
|
|
124
|
+
|
|
125
|
+
app = FastAPI()
|
|
126
|
+
app.mount("/static", PrecompressedStaticFiles(directory=tmp_path), name="static")
|
|
127
|
+
async with httpx.AsyncClient(
|
|
128
|
+
transport=httpx.ASGITransport(app=app), base_url="http://testserver"
|
|
129
|
+
) as c:
|
|
130
|
+
resp = await c.get("/static/dist/assets/main-abc123.js")
|
|
131
|
+
assert "immutable" in resp.headers.get("cache-control", "")
|
|
132
|
+
|
|
133
|
+
async def test_non_hashed_paths_do_not_get_immutable(self, tmp_path: Path) -> None:
|
|
134
|
+
(tmp_path / "favicon.ico").write_bytes(b"\x00" * 100)
|
|
135
|
+
|
|
136
|
+
app = FastAPI()
|
|
137
|
+
app.mount("/static", PrecompressedStaticFiles(directory=tmp_path), name="static")
|
|
138
|
+
async with httpx.AsyncClient(
|
|
139
|
+
transport=httpx.ASGITransport(app=app), base_url="http://testserver"
|
|
140
|
+
) as c:
|
|
141
|
+
resp = await c.get("/static/favicon.ico")
|
|
142
|
+
assert "immutable" not in resp.headers.get("cache-control", "")
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Proxy-header handling (X-Forwarded-Proto / X-Forwarded-For).
|
|
2
|
+
|
|
3
|
+
Behind a TLS-terminating reverse proxy the app sees plain ``http`` on the
|
|
4
|
+
container socket, so ``request.url.scheme`` is wrong. inertia-python copies
|
|
5
|
+
that scheme into the page object's absolute ``url``; the browser then refuses
|
|
6
|
+
the cross-scheme ``history.pushState`` and login breaks (GH #223).
|
|
7
|
+
|
|
8
|
+
When ``SM_TRUSTED_PROXY`` is set, the host registers uvicorn's
|
|
9
|
+
``ProxyHeadersMiddleware`` as the outermost middleware so the corrected scheme
|
|
10
|
+
and client IP are visible to every downstream middleware (logging included).
|
|
11
|
+
Left unset (the default) nothing changes — the header is ignored.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
import pytest
|
|
18
|
+
from simple_module_hosting.app_builder import create_app
|
|
19
|
+
from simple_module_hosting.settings import Settings
|
|
20
|
+
from simple_module_test.fixtures import _create_all_tables
|
|
21
|
+
|
|
22
|
+
_LOGIN_PATH = "/users/login"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _names(app) -> tuple[str, ...]:
|
|
26
|
+
return tuple(m.cls.__name__ for m in app.user_middleware)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _settings(**overrides) -> Settings:
|
|
30
|
+
base = {
|
|
31
|
+
"database_url": "sqlite+aiosqlite:///:memory:",
|
|
32
|
+
"environment": "testing",
|
|
33
|
+
"secret_key": "x" * 32,
|
|
34
|
+
"multi_tenant": False,
|
|
35
|
+
}
|
|
36
|
+
base.update(overrides)
|
|
37
|
+
return Settings(**base)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class TestMiddlewareWiring:
|
|
41
|
+
def test_absent_by_default(self) -> None:
|
|
42
|
+
"""No SM_TRUSTED_PROXY → ProxyHeadersMiddleware must not be installed."""
|
|
43
|
+
app = create_app(_settings())
|
|
44
|
+
assert "ProxyHeadersMiddleware" not in _names(app)
|
|
45
|
+
|
|
46
|
+
def test_present_and_outermost_when_configured(self) -> None:
|
|
47
|
+
"""ProxyHeaders must run before every other middleware.
|
|
48
|
+
|
|
49
|
+
``app.user_middleware`` is in execution order (FastAPI reverses the
|
|
50
|
+
LIFO add_middleware stack), so the proxy middleware must be first —
|
|
51
|
+
otherwise RequestLogging would log the proxy's address, and Inertia
|
|
52
|
+
would already have read the wrong scheme.
|
|
53
|
+
"""
|
|
54
|
+
app = create_app(_settings(trusted_proxy="*"))
|
|
55
|
+
names = _names(app)
|
|
56
|
+
assert names[0] == "ProxyHeadersMiddleware"
|
|
57
|
+
|
|
58
|
+
def test_trusted_hosts_passed_through(self) -> None:
|
|
59
|
+
"""The configured value reaches uvicorn's middleware as trusted_hosts."""
|
|
60
|
+
app = create_app(_settings(trusted_proxy="10.0.0.0/8,127.0.0.1"))
|
|
61
|
+
proxy = next(m for m in app.user_middleware if m.cls.__name__ == "ProxyHeadersMiddleware")
|
|
62
|
+
assert proxy.kwargs["trusted_hosts"] == "10.0.0.0/8,127.0.0.1"
|
|
63
|
+
|
|
64
|
+
def test_surrounding_whitespace_stripped(self) -> None:
|
|
65
|
+
"""A stray space must not silently defeat ``*`` trust.
|
|
66
|
+
|
|
67
|
+
uvicorn computes always-trust as ``raw in ("*", ["*"])`` *before*
|
|
68
|
+
stripping, so ``"* "`` would be parsed as a literal host that never
|
|
69
|
+
matches any client — silently re-introducing GH #223 with no error.
|
|
70
|
+
The setting must normalize the value first.
|
|
71
|
+
"""
|
|
72
|
+
app = create_app(_settings(trusted_proxy="* "))
|
|
73
|
+
proxy = next(m for m in app.user_middleware if m.cls.__name__ == "ProxyHeadersMiddleware")
|
|
74
|
+
assert proxy.kwargs["trusted_hosts"] == "*"
|
|
75
|
+
|
|
76
|
+
def test_blank_value_disables(self) -> None:
|
|
77
|
+
"""Whitespace-only SM_TRUSTED_PROXY is treated as unset (no middleware)."""
|
|
78
|
+
app = create_app(_settings(trusted_proxy=" "))
|
|
79
|
+
assert "ProxyHeadersMiddleware" not in _names(app)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
async def _client_for(settings: Settings):
|
|
83
|
+
"""Build an app + lifespan-started client mirroring the shared fixtures."""
|
|
84
|
+
app = create_app(settings)
|
|
85
|
+
await _create_all_tables(app.state.sm.db.engine)
|
|
86
|
+
ctx = app.router.lifespan_context(app)
|
|
87
|
+
await ctx.__aenter__()
|
|
88
|
+
transport = httpx.ASGITransport(app=app)
|
|
89
|
+
client = httpx.AsyncClient(transport=transport, base_url="http://testserver")
|
|
90
|
+
return client, ctx
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class TestSchemeCorrection:
|
|
94
|
+
@pytest.mark.parametrize(
|
|
95
|
+
("trusted_proxy", "expected_scheme"),
|
|
96
|
+
[
|
|
97
|
+
("*", "https"), # trusted proxy → X-Forwarded-Proto honored
|
|
98
|
+
(None, "http"), # no trust configured → header ignored
|
|
99
|
+
],
|
|
100
|
+
)
|
|
101
|
+
async def test_inertia_page_url_scheme(
|
|
102
|
+
self, trusted_proxy: str | None, expected_scheme: str
|
|
103
|
+
) -> None:
|
|
104
|
+
"""The Inertia page object's absolute url reflects X-Forwarded-Proto.
|
|
105
|
+
|
|
106
|
+
This is the exact thing the browser chokes on: inertia-python sets
|
|
107
|
+
``"url": str(request.url)``, scheme included, and embeds it in the
|
|
108
|
+
``data-page`` blob of the rendered HTML.
|
|
109
|
+
"""
|
|
110
|
+
client, ctx = await _client_for(_settings(trusted_proxy=trusted_proxy))
|
|
111
|
+
try:
|
|
112
|
+
resp = await client.get(_LOGIN_PATH, headers={"X-Forwarded-Proto": "https"})
|
|
113
|
+
assert resp.status_code == 200
|
|
114
|
+
assert f"{expected_scheme}://testserver{_LOGIN_PATH}" in resp.text
|
|
115
|
+
wrong = "https" if expected_scheme == "http" else "http"
|
|
116
|
+
assert f"{wrong}://testserver{_LOGIN_PATH}" not in resp.text
|
|
117
|
+
finally:
|
|
118
|
+
await client.aclose()
|
|
119
|
+
await ctx.__aexit__(None, None, None)
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Responses must be compressed when the client advertises support.
|
|
2
|
+
|
|
3
|
+
The built frontend ships ~139 KB of CSS and ~490 KB of JS. Uncompressed that
|
|
4
|
+
is the dominant cost of a cold page load — far larger than anything on the
|
|
5
|
+
server request path. Compression cuts the CSS alone by ~85%.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
import pytest
|
|
12
|
+
|
|
13
|
+
_OK = 200
|
|
14
|
+
_GZIP = "gzip"
|
|
15
|
+
# Below Starlette's default minimum_size, compression is skipped because the
|
|
16
|
+
# gzip framing would cost more than it saves.
|
|
17
|
+
_TINY_BODY_BYTES = 100
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class TestResponseCompression:
|
|
21
|
+
async def test_large_response_is_gzipped_when_accepted(
|
|
22
|
+
self, authenticated_client: httpx.AsyncClient
|
|
23
|
+
) -> None:
|
|
24
|
+
resp = await authenticated_client.get(
|
|
25
|
+
"/api/permissions/", headers={"Accept-Encoding": "gzip"}
|
|
26
|
+
)
|
|
27
|
+
assert resp.status_code == _OK
|
|
28
|
+
# httpx transparently decodes, so assert on the header the server set.
|
|
29
|
+
assert resp.headers.get("content-encoding") == _GZIP
|
|
30
|
+
|
|
31
|
+
async def test_response_is_not_gzipped_when_not_accepted(
|
|
32
|
+
self, authenticated_client: httpx.AsyncClient
|
|
33
|
+
) -> None:
|
|
34
|
+
"""A client that doesn't advertise gzip must still get a usable body."""
|
|
35
|
+
resp = await authenticated_client.get(
|
|
36
|
+
"/api/permissions/", headers={"Accept-Encoding": "identity"}
|
|
37
|
+
)
|
|
38
|
+
assert resp.status_code == _OK
|
|
39
|
+
assert resp.headers.get("content-encoding") != _GZIP
|
|
40
|
+
assert resp.json() is not None
|
|
41
|
+
|
|
42
|
+
async def test_compression_does_not_corrupt_the_payload(
|
|
43
|
+
self, authenticated_client: httpx.AsyncClient
|
|
44
|
+
) -> None:
|
|
45
|
+
"""The decoded body must match what the uncompressed route returns."""
|
|
46
|
+
compressed = await authenticated_client.get(
|
|
47
|
+
"/api/permissions/", headers={"Accept-Encoding": "gzip"}
|
|
48
|
+
)
|
|
49
|
+
plain = await authenticated_client.get(
|
|
50
|
+
"/api/permissions/", headers={"Accept-Encoding": "identity"}
|
|
51
|
+
)
|
|
52
|
+
assert compressed.json() == plain.json()
|
|
53
|
+
|
|
54
|
+
async def test_health_endpoint_still_works(self, client: httpx.AsyncClient) -> None:
|
|
55
|
+
"""Small responses fall under the size threshold and pass through."""
|
|
56
|
+
resp = await client.get("/health", headers={"Accept-Encoding": "gzip"})
|
|
57
|
+
assert resp.status_code == _OK
|
|
58
|
+
assert resp.json() is not None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class TestCompressionMiddlewareOrder:
|
|
62
|
+
def test_gzip_runs_inside_correlation_id(self, app) -> None:
|
|
63
|
+
"""GZip must sit inside CorrelationId/RequestLogging.
|
|
64
|
+
|
|
65
|
+
Those two set response headers and read request state; compression is
|
|
66
|
+
a transport concern that should wrap only the response body beneath
|
|
67
|
+
them. Starlette's stack is LIFO, so a middleware added *later* is
|
|
68
|
+
outermost — GZip must therefore appear earlier in user_middleware
|
|
69
|
+
than CorrelationId.
|
|
70
|
+
"""
|
|
71
|
+
from simple_module_hosting._observability import CorrelationIdMiddleware
|
|
72
|
+
from starlette.middleware.gzip import GZipMiddleware
|
|
73
|
+
|
|
74
|
+
classes = [m.cls for m in app.user_middleware]
|
|
75
|
+
assert GZipMiddleware in classes, "GZipMiddleware is not installed"
|
|
76
|
+
assert classes.index(GZipMiddleware) > classes.index(CorrelationIdMiddleware)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@pytest.mark.parametrize("encoding", ["gzip", "gzip, deflate", "gzip, deflate, br"])
|
|
80
|
+
async def test_common_browser_accept_encodings(
|
|
81
|
+
authenticated_client: httpx.AsyncClient, encoding: str
|
|
82
|
+
) -> None:
|
|
83
|
+
"""Real browsers send several forms; all must yield a valid response."""
|
|
84
|
+
resp = await authenticated_client.get(
|
|
85
|
+
"/api/permissions/", headers={"Accept-Encoding": encoding}
|
|
86
|
+
)
|
|
87
|
+
assert resp.status_code == _OK
|
|
88
|
+
assert resp.json() is not None
|
|
File without changes
|
|
File without changes
|
{simple_module_hosting-0.0.22 → simple_module_hosting-0.0.25}/simple_module_hosting/__init__.py
RENAMED
|
File without changes
|
{simple_module_hosting-0.0.22 → simple_module_hosting-0.0.25}/simple_module_hosting/__main__.py
RENAMED
|
File without changes
|
|
File without changes
|
{simple_module_hosting-0.0.22 → simple_module_hosting-0.0.25}/simple_module_hosting/_hydrate_step.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{simple_module_hosting-0.0.22 → simple_module_hosting-0.0.25}/simple_module_hosting/health.py
RENAMED
|
File without changes
|
{simple_module_hosting-0.0.22 → simple_module_hosting-0.0.25}/simple_module_hosting/host_cli.py
RENAMED
|
File without changes
|
{simple_module_hosting-0.0.22 → simple_module_hosting-0.0.25}/simple_module_hosting/host_settings.py
RENAMED
|
File without changes
|
{simple_module_hosting-0.0.22 → simple_module_hosting-0.0.25}/simple_module_hosting/i18n_deps.py
RENAMED
|
File without changes
|
{simple_module_hosting-0.0.22 → simple_module_hosting-0.0.25}/simple_module_hosting/i18n_manifest.py
RENAMED
|
File without changes
|
|
File without changes
|
{simple_module_hosting-0.0.22 → simple_module_hosting-0.0.25}/simple_module_hosting/inertia_deps.py
RENAMED
|
File without changes
|
{simple_module_hosting-0.0.22 → simple_module_hosting-0.0.25}/simple_module_hosting/inertia_utils.py
RENAMED
|
File without changes
|
{simple_module_hosting-0.0.22 → simple_module_hosting-0.0.25}/simple_module_hosting/logging.py
RENAMED
|
File without changes
|
{simple_module_hosting-0.0.22 → simple_module_hosting-0.0.25}/simple_module_hosting/manifest.py
RENAMED
|
File without changes
|
{simple_module_hosting-0.0.22 → simple_module_hosting-0.0.25}/simple_module_hosting/middleware.py
RENAMED
|
File without changes
|
{simple_module_hosting-0.0.22 → simple_module_hosting-0.0.25}/simple_module_hosting/migrations.py
RENAMED
|
File without changes
|
{simple_module_hosting-0.0.22 → simple_module_hosting-0.0.25}/simple_module_hosting/permissions.py
RENAMED
|
File without changes
|
{simple_module_hosting-0.0.22 → simple_module_hosting-0.0.25}/simple_module_hosting/py.typed
RENAMED
|
File without changes
|
{simple_module_hosting-0.0.22 → simple_module_hosting-0.0.25}/simple_module_hosting/redirects.py
RENAMED
|
File without changes
|
{simple_module_hosting-0.0.22 → simple_module_hosting-0.0.25}/simple_module_hosting/settings.py
RENAMED
|
File without changes
|
{simple_module_hosting-0.0.22 → simple_module_hosting-0.0.25}/simple_module_hosting/shared_props.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{simple_module_hosting-0.0.22 → simple_module_hosting-0.0.25}/tests/test_check_migrations.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{simple_module_hosting-0.0.22 → simple_module_hosting-0.0.25}/tests/test_hosting_permissions.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{simple_module_hosting-0.0.22 → simple_module_hosting-0.0.25}/tests/test_inertia_manifest.py
RENAMED
|
File without changes
|
{simple_module_hosting-0.0.22 → simple_module_hosting-0.0.25}/tests/test_inertia_shared_providers.py
RENAMED
|
File without changes
|
|
File without changes
|
{simple_module_hosting-0.0.22 → simple_module_hosting-0.0.25}/tests/test_locale_middleware.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{simple_module_hosting-0.0.22 → simple_module_hosting-0.0.25}/tests/test_session_cookie_security.py
RENAMED
|
File without changes
|
|
File without changes
|
{simple_module_hosting-0.0.22 → simple_module_hosting-0.0.25}/tests/test_settings_secrets.py
RENAMED
|
File without changes
|
|
File without changes
|
{simple_module_hosting-0.0.22 → simple_module_hosting-0.0.25}/tests/test_strict_discovery_wiring.py
RENAMED
|
File without changes
|
{simple_module_hosting-0.0.22 → simple_module_hosting-0.0.25}/tests/test_tenant_middleware.py
RENAMED
|
File without changes
|
|
File without changes
|