simple-module-hosting 0.0.33__tar.gz → 0.0.35__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.33 → simple_module_hosting-0.0.35}/PKG-INFO +4 -4
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/pyproject.toml +5 -4
- simple_module_hosting-0.0.35/simple_module_hosting/_dev_boot.py +83 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/simple_module_hosting/_error_handlers.py +18 -7
- simple_module_hosting-0.0.35/simple_module_hosting/_favicon.py +94 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/simple_module_hosting/_inertia_setup.py +30 -73
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/simple_module_hosting/_phase_helpers.py +12 -6
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/simple_module_hosting/_registrations.py +2 -0
- simple_module_hosting-0.0.35/simple_module_hosting/_tenant.py +97 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/simple_module_hosting/app_builder.py +16 -25
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/simple_module_hosting/host_settings.py +25 -2
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/simple_module_hosting/i18n_manifest.py +20 -5
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/simple_module_hosting/inertia_deps.py +1 -1
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/simple_module_hosting/manifest.py +4 -17
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/simple_module_hosting/middleware.py +3 -57
- simple_module_hosting-0.0.35/simple_module_hosting/page_globs.py +52 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/simple_module_hosting/permissions.py +36 -15
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/simple_module_hosting/session.py +35 -10
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/tests/test_branding_head.py +32 -6
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/tests/test_csp_module_sources.py +1 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/tests/test_diagnostics_state.py +2 -4
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/tests/test_error_page_shared_props.py +46 -3
- simple_module_hosting-0.0.35/tests/test_favicon_default.py +72 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/tests/test_host_settings_fields.py +2 -1
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/tests/test_i18n_manifest.py +52 -1
- simple_module_hosting-0.0.35/tests/test_invalidation_wiring.py +250 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/tests/test_manifest.py +48 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/tests/test_middleware_order.py +10 -0
- simple_module_hosting-0.0.35/tests/test_proxy_headers.py +224 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/tests/test_session_cookie_window.py +37 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/tests/test_strict_discovery_wiring.py +1 -1
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/tests/test_tenant_middleware.py +56 -3
- simple_module_hosting-0.0.35/tests/test_tenant_scope_helpers.py +99 -0
- simple_module_hosting-0.0.33/simple_module_hosting/_inertia_json.py +0 -86
- simple_module_hosting-0.0.33/tests/test_inertia_json_encoder.py +0 -83
- simple_module_hosting-0.0.33/tests/test_inertia_manifest.py +0 -71
- simple_module_hosting-0.0.33/tests/test_proxy_headers.py +0 -122
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/.gitignore +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/LICENSE +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/README.md +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/simple_module_hosting/__init__.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/simple_module_hosting/__main__.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/simple_module_hosting/_db_health.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/simple_module_hosting/_host_services.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/simple_module_hosting/_hydrate_step.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/simple_module_hosting/_inertia_cache.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/simple_module_hosting/_inertia_shared.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/simple_module_hosting/_lifespan.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/simple_module_hosting/_module_routes.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/simple_module_hosting/_observability.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/simple_module_hosting/_preapp_config.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/simple_module_hosting/_secret_key.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/simple_module_hosting/assets.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/simple_module_hosting/bootstrap_settings.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/simple_module_hosting/csrf.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/simple_module_hosting/health.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/simple_module_hosting/host_cli.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/simple_module_hosting/i18n_deps.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/simple_module_hosting/i18n_middleware.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/simple_module_hosting/inertia_utils.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/simple_module_hosting/logging.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/simple_module_hosting/maintenance.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/simple_module_hosting/migrations.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/simple_module_hosting/py.typed +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/simple_module_hosting/redirects.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/simple_module_hosting/settings.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/simple_module_hosting/setup_gate.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/simple_module_hosting/shared_props.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/simple_module_hosting/static_files.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/tests/test_api_error_negotiation.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/tests/test_app.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/tests/test_auth_provider_setting.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/tests/test_boot_drift_check.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/tests/test_check_migrations.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/tests/test_db_health_check.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/tests/test_design_packs_wiring.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/tests/test_error_status_coverage.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/tests/test_health.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/tests/test_host_cli.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/tests/test_hosting_csrf.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/tests/test_hosting_permissions.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/tests/test_i18n_types_cover_installed_modules.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/tests/test_inertia_cache.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/tests/test_inertia_i18n_shared_props.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/tests/test_inertia_shared_providers.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/tests/test_lifespan_order.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/tests/test_locale_middleware.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/tests/test_logging.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/tests/test_maintenance_mode.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/tests/test_menu_urls_are_canonical.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/tests/test_migration_listing.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/tests/test_migration_multihead.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/tests/test_preapp_config.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/tests/test_precompressed_static.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/tests/test_redirects.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/tests/test_response_compression.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/tests/test_secret_key_bootstrap.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/tests/test_session_cookie_security.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/tests/test_settings_i18n.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/tests/test_settings_secrets.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/tests/test_setup_gate.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/tests/test_setup_password_policy.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/tests/test_setup_public_routes.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/tests/test_setup_registry.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/tests/test_setup_routes.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/tests/test_sqlite_path_anchor.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/tests/test_static_caching.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.35}/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.
|
|
3
|
+
Version: 0.0.35
|
|
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
|
|
@@ -22,13 +22,13 @@ Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
|
|
|
22
22
|
Classifier: Typing :: Typed
|
|
23
23
|
Requires-Python: >=3.12
|
|
24
24
|
Requires-Dist: click>=8.1
|
|
25
|
-
Requires-Dist: fastapi-inertia>=1.0
|
|
26
25
|
Requires-Dist: fastapi>=0.115
|
|
27
26
|
Requires-Dist: httpx>=0.27
|
|
28
27
|
Requires-Dist: itsdangerous>=2.0
|
|
29
28
|
Requires-Dist: jinja2>=3.1
|
|
30
|
-
Requires-Dist: simple-module-core==0.0.
|
|
31
|
-
Requires-Dist: simple-module-db==0.0.
|
|
29
|
+
Requires-Dist: simple-module-core==0.0.35
|
|
30
|
+
Requires-Dist: simple-module-db==0.0.35
|
|
31
|
+
Requires-Dist: simple-module-inertia==0.0.35
|
|
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.35"
|
|
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"
|
|
@@ -23,15 +23,15 @@ classifiers = [
|
|
|
23
23
|
dependencies = [
|
|
24
24
|
"click>=8.1",
|
|
25
25
|
"fastapi>=0.115",
|
|
26
|
-
"fastapi-inertia>=1.0",
|
|
27
26
|
"httpx>=0.27",
|
|
28
27
|
# SessionMiddleware (starlette.middleware.sessions) imports itsdangerous at
|
|
29
28
|
# module import time; starlette treats it as an optional extra, so declare
|
|
30
29
|
# it directly — out-of-tree hosts have nothing else pulling it in.
|
|
31
30
|
"itsdangerous>=2.0",
|
|
32
31
|
"jinja2>=3.1",
|
|
33
|
-
"simple_module_core==0.0.
|
|
34
|
-
"simple_module_db==0.0.
|
|
32
|
+
"simple_module_core==0.0.35",
|
|
33
|
+
"simple_module_db==0.0.35",
|
|
34
|
+
"simple_module_inertia==0.0.35",
|
|
35
35
|
"starlette>=0.44",
|
|
36
36
|
"tomlkit>=0.13",
|
|
37
37
|
"uvicorn[standard]>=0.34",
|
|
@@ -59,3 +59,4 @@ packages = ["simple_module_hosting"]
|
|
|
59
59
|
[tool.uv.sources]
|
|
60
60
|
simple_module_core = { workspace = true }
|
|
61
61
|
simple_module_db = { workspace = true }
|
|
62
|
+
simple_module_inertia = { workspace = true }
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Development-only boot work: the diagnostics run and the frontend artifacts.
|
|
2
|
+
|
|
3
|
+
Extracted from ``app_builder`` so that file reads as the phase sequence and
|
|
4
|
+
nothing else. Everything here is gated on ``settings.is_development`` at the one
|
|
5
|
+
call site, and everything here reads or writes the *source tree* — a deployed
|
|
6
|
+
wheel has no ``host/client_app`` to write a manifest into and no module sources
|
|
7
|
+
for the AST checks to walk, which is why none of it belongs on a production boot
|
|
8
|
+
path.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import logging
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import TYPE_CHECKING
|
|
16
|
+
|
|
17
|
+
from simple_module_core.diagnostics import DiagnosticLevel, print_diagnostics, run_diagnostics
|
|
18
|
+
|
|
19
|
+
from simple_module_hosting.i18n_manifest import emit_frontend_types_for_modules
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from simple_module_core import ModuleBase
|
|
23
|
+
from simple_module_core.services import DiagnosticsState
|
|
24
|
+
|
|
25
|
+
from simple_module_hosting.settings import Settings
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
__all__ = ["run_dev_boot"]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def run_dev_boot(
|
|
33
|
+
settings: Settings,
|
|
34
|
+
modules: list[ModuleBase],
|
|
35
|
+
installed_modules: list[ModuleBase],
|
|
36
|
+
*,
|
|
37
|
+
i18n_extra,
|
|
38
|
+
diagnostics_state: DiagnosticsState,
|
|
39
|
+
project_root: Path,
|
|
40
|
+
) -> None:
|
|
41
|
+
"""Run the module diagnostics and emit the frontend's generated files.
|
|
42
|
+
|
|
43
|
+
``diagnostics_state.runner`` is left behind on purpose, not just used: the
|
|
44
|
+
in-app Doctor screen re-invokes this exact call through ``rerun()``, and
|
|
45
|
+
re-deriving the arguments from a request would mean re-discovering modules.
|
|
46
|
+
|
|
47
|
+
An ERROR-level diagnostic ends the process. A developer whose module has a
|
|
48
|
+
duplicate name or a framework→plugin import gets one clear message here
|
|
49
|
+
instead of an inexplicable failure several phases later — and the same
|
|
50
|
+
checks run at production boot, where the app would refuse to start anyway.
|
|
51
|
+
"""
|
|
52
|
+
diagnostics_state.runner = lambda: run_diagnostics(
|
|
53
|
+
modules,
|
|
54
|
+
i18n_supported_locales=settings.i18n_supported_locales,
|
|
55
|
+
i18n_default_locale=settings.i18n_default_locale,
|
|
56
|
+
i18n_extra_sources=i18n_extra,
|
|
57
|
+
)
|
|
58
|
+
diagnostics = diagnostics_state.rerun()
|
|
59
|
+
errors = [d for d in diagnostics if d.level == DiagnosticLevel.ERROR]
|
|
60
|
+
if diagnostics:
|
|
61
|
+
print_diagnostics(diagnostics)
|
|
62
|
+
if errors:
|
|
63
|
+
raise SystemExit(f"Module diagnostics: {len(errors)} error(s). Fix before continuing.")
|
|
64
|
+
|
|
65
|
+
_write_pages_manifest(modules, project_root)
|
|
66
|
+
emit_frontend_types_for_modules(settings, installed_modules, project_root)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _write_pages_manifest(modules: list[ModuleBase], project_root: Path) -> None:
|
|
70
|
+
"""Tell Vite where pages shipped inside pip-installed wheels live.
|
|
71
|
+
|
|
72
|
+
Swallows its own failure: a missing or unwritable ``client_app`` must not
|
|
73
|
+
stop the API from booting, and the symptom — pages the frontend cannot find
|
|
74
|
+
— is loud enough on its own. See ``scaffolding.py``.
|
|
75
|
+
"""
|
|
76
|
+
try:
|
|
77
|
+
from simple_module_hosting.manifest import write_module_pages_manifest
|
|
78
|
+
|
|
79
|
+
client_app = project_root / "host" / "client_app"
|
|
80
|
+
if client_app.is_dir():
|
|
81
|
+
write_module_pages_manifest(modules, client_app)
|
|
82
|
+
except Exception:
|
|
83
|
+
logger.exception("Failed to write module pages manifest — frontend may miss pages")
|
|
@@ -9,13 +9,13 @@ from http import HTTPStatus
|
|
|
9
9
|
from fastapi.encoders import jsonable_encoder
|
|
10
10
|
from fastapi.exceptions import RequestValidationError
|
|
11
11
|
from fastapi.responses import JSONResponse
|
|
12
|
-
from
|
|
12
|
+
from simple_module_core.exceptions import NotFoundError
|
|
13
|
+
from simple_module_inertia import (
|
|
13
14
|
Inertia,
|
|
14
15
|
InertiaConfig,
|
|
15
16
|
InertiaVersionConflictException,
|
|
16
17
|
inertia_version_conflict_exception_handler,
|
|
17
18
|
)
|
|
18
|
-
from simple_module_core.exceptions import NotFoundError
|
|
19
19
|
from starlette.exceptions import HTTPException
|
|
20
20
|
from starlette.requests import Request
|
|
21
21
|
from starlette.responses import Response
|
|
@@ -172,11 +172,22 @@ async def render_error_page(
|
|
|
172
172
|
# thing that is missing when the app is half-built, and an error page
|
|
173
173
|
# that raises while reporting an error leaves the caller with nothing.
|
|
174
174
|
config: InertiaConfig = request.app.state.sm.inertia_config
|
|
175
|
-
|
|
176
|
-
#
|
|
177
|
-
#
|
|
178
|
-
#
|
|
179
|
-
#
|
|
175
|
+
# Prefer the app's configured dependency over constructing Inertia
|
|
176
|
+
# directly, so the error page is built the same way as every other
|
|
177
|
+
# page. The raw construction is the fallback for a half-built app —
|
|
178
|
+
# the case this whole handler exists to survive. Both paths emit a
|
|
179
|
+
# root-relative page url: the adapter does that by construction now,
|
|
180
|
+
# so the fallback no longer needs a separate patch to avoid the
|
|
181
|
+
# cross-scheme `pushState` SecurityError behind a TLS proxy.
|
|
182
|
+
inertia_dep = getattr(request.app.state, "inertia_dependency", None)
|
|
183
|
+
if inertia_dep is not None:
|
|
184
|
+
inertia = inertia_dep(request, None)
|
|
185
|
+
else:
|
|
186
|
+
inertia = Inertia(request, config)
|
|
187
|
+
# This does not go through get_inertia, so the share step has to be
|
|
188
|
+
# repeated here. Without it the error page renders raw translation keys
|
|
189
|
+
# (host.error.not_found_title) and loses auth/menus, so a signed-in
|
|
190
|
+
# user's 404 has no layout.
|
|
180
191
|
shared = getattr(request.state, "inertia_shared", None)
|
|
181
192
|
if shared:
|
|
182
193
|
inertia.share(**shared)
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""The favicon an install has before anyone uploads one.
|
|
2
|
+
|
|
3
|
+
Without a default, ``index.html`` emitted no ``<link rel="icon">`` at all, so
|
|
4
|
+
every browser fell back to its implicit ``/favicon.ico`` request — which this
|
|
5
|
+
app does not route. Anonymous visitors got a 302 to the login page (an HTML
|
|
6
|
+
document offered as an image), authenticated ones a 404, and either way a
|
|
7
|
+
console error on every full page load, on a brand-new install that has done
|
|
8
|
+
nothing wrong.
|
|
9
|
+
|
|
10
|
+
Rendered as an SVG ``data:`` URI rather than a file so there is nothing to
|
|
11
|
+
mount, nothing to exempt from auth, and nothing to ship in the image: the
|
|
12
|
+
static mount is a build artifact (``host/static/dist`` is generated), and a
|
|
13
|
+
route would need its own public-route rule. ``img-src`` already allows
|
|
14
|
+
``data:``, so the strict CSP is untouched.
|
|
15
|
+
|
|
16
|
+
The mark mirrors ``BrandingMark``'s fallback badge — the app's initial on the
|
|
17
|
+
brand gradient — so the tab icon and the in-app logo are the same thing, for
|
|
18
|
+
whatever the app is named. An uploaded favicon still wins; this is only the
|
|
19
|
+
floor.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from functools import lru_cache
|
|
25
|
+
from urllib.parse import quote
|
|
26
|
+
from xml.sax.saxutils import escape as _xml_escape
|
|
27
|
+
|
|
28
|
+
#: sRGB for `--color-primary-600` / `--color-primary-800`
|
|
29
|
+
#: (`oklch(0.59 0.14 158)` / `oklch(0.42 0.09 175)`), the two stops of
|
|
30
|
+
#: `BRAND_ACCENT`. Hard-coded because a data URI cannot read a CSS custom
|
|
31
|
+
#: property — kept beside the tokens they mirror in `ui/styles/globals.css`.
|
|
32
|
+
_ACCENT_FROM = "#00955c"
|
|
33
|
+
_ACCENT_TO = "#005c4a"
|
|
34
|
+
|
|
35
|
+
_SVG = (
|
|
36
|
+
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">'
|
|
37
|
+
'<defs><linearGradient id="a" x1="0" y1="0" x2="1" y2="1">'
|
|
38
|
+
'<stop offset="0" stop-color="{from_}"/>'
|
|
39
|
+
'<stop offset="1" stop-color="{to}"/>'
|
|
40
|
+
"</linearGradient></defs>"
|
|
41
|
+
'<rect width="64" height="64" rx="14" fill="url(#a)"/>'
|
|
42
|
+
'<text x="32" y="45" text-anchor="middle" fill="#fff" font-size="34" '
|
|
43
|
+
'font-weight="700" font-family="system-ui,-apple-system,Segoe UI,'
|
|
44
|
+
'Roboto,sans-serif">{initial}</text>'
|
|
45
|
+
"</svg>"
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@lru_cache(maxsize=64)
|
|
50
|
+
def default_favicon_data_uri(app_name: str, accent: str = "") -> str:
|
|
51
|
+
"""An SVG data URI showing ``app_name``'s initial on the brand gradient.
|
|
52
|
+
|
|
53
|
+
``accent`` is branding's configured primary colour: when set it replaces
|
|
54
|
+
both gradient stops with the flat brand colour, so a customised install's
|
|
55
|
+
tab icon matches the rest of its chrome. Already validated as a hex colour
|
|
56
|
+
by ``BrandingSettings``; anything else is ignored rather than trusted into
|
|
57
|
+
the markup.
|
|
58
|
+
|
|
59
|
+
Pure function of its two arguments, cached: ``branding_head`` calls it on
|
|
60
|
+
every full page render, and with no favicon uploaded — the state every
|
|
61
|
+
install starts in — that means every single page load rebuilds and
|
|
62
|
+
re-quotes the same SVG. The cache is keyed on ``(app_name, accent)``, both
|
|
63
|
+
of which only change when an admin edits branding settings.
|
|
64
|
+
"""
|
|
65
|
+
initial = _initial(app_name)
|
|
66
|
+
from_, to = (accent, accent) if _is_hex_colour(accent) else (_ACCENT_FROM, _ACCENT_TO)
|
|
67
|
+
svg = _SVG.format(from_=from_, to=to, initial=initial)
|
|
68
|
+
# Everything structural is encoded — `#` above all, or the browser reads
|
|
69
|
+
# the gradient reference as the URI's fragment and the shape loses its
|
|
70
|
+
# fill, but also `"`, `<`, `>` and spaces so the result stays a single
|
|
71
|
+
# valid attribute value without relying on the template's escaping. The
|
|
72
|
+
# safe set is only characters that are already legal unencoded in a URI.
|
|
73
|
+
return "data:image/svg+xml," + quote(svg, safe="/:=,;()-_.'")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _initial(app_name: str) -> str:
|
|
77
|
+
"""First character of the app name, XML-escaped, defaulting to ``S``.
|
|
78
|
+
|
|
79
|
+
Matches ``BrandingMark``'s ``appName.trim().charAt(0).toUpperCase() || 'S'``
|
|
80
|
+
so the tab and the sidebar badge never disagree.
|
|
81
|
+
"""
|
|
82
|
+
raw = (app_name or "").strip()[:1].upper() or "S"
|
|
83
|
+
return _xml_escape(raw)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _is_hex_colour(value: str) -> bool:
|
|
87
|
+
"""Whether ``value`` is a ``#rgb``/``#rrggbb`` literal safe to interpolate."""
|
|
88
|
+
if not value.startswith("#"):
|
|
89
|
+
return False
|
|
90
|
+
body = value[1:]
|
|
91
|
+
return len(body) in (3, 6) and all(c in "0123456789abcdefABCDEF" for c in body)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
__all__ = ["default_favicon_data_uri"]
|
|
@@ -1,18 +1,15 @@
|
|
|
1
|
-
"""Configure
|
|
1
|
+
"""Configure simple_module_inertia with the Jinja2 template."""
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
import hashlib
|
|
6
|
-
import json
|
|
7
5
|
import logging
|
|
8
|
-
import tempfile
|
|
9
6
|
from pathlib import Path
|
|
10
7
|
|
|
11
8
|
from fastapi import FastAPI
|
|
12
|
-
from
|
|
9
|
+
from simple_module_inertia import InertiaConfig, inertia_dependency_factory
|
|
13
10
|
from starlette.requests import Request
|
|
14
11
|
|
|
15
|
-
from simple_module_hosting.
|
|
12
|
+
from simple_module_hosting._favicon import default_favicon_data_uri
|
|
16
13
|
from simple_module_hosting.settings import Settings
|
|
17
14
|
|
|
18
15
|
logger = logging.getLogger(__name__)
|
|
@@ -26,6 +23,12 @@ _ROOT_DIRECTORY = "."
|
|
|
26
23
|
# branding's own default so the unbranded title is identical everywhere.
|
|
27
24
|
_DEFAULT_APP_NAME = "SimpleModule"
|
|
28
25
|
|
|
26
|
+
# Built assets are served from the "/static" mount under "dist/", so production
|
|
27
|
+
# asset URLs are prefixed with "static/dist". The manifest is read where Vite
|
|
28
|
+
# writes it; the adapter looks the entry up by Vite's own key.
|
|
29
|
+
_ASSETS_PREFIX = "static/dist"
|
|
30
|
+
_VITE_MANIFEST_RELPATH = Path("static") / "dist" / ".vite" / "manifest.json"
|
|
31
|
+
|
|
29
32
|
|
|
30
33
|
def branding_head(request: Request) -> dict:
|
|
31
34
|
"""Branding metadata for the root template's ``<head>``.
|
|
@@ -40,72 +43,38 @@ def branding_head(request: Request) -> dict:
|
|
|
40
43
|
The favicon URL is read from the module rather than assembled here: branding
|
|
41
44
|
owns its route shape, and framework code must not reach into a plugin
|
|
42
45
|
(SM009). ``BrandingHead`` still applies it client-side too, so a favicon
|
|
43
|
-
changed at runtime updates without a reload.
|
|
46
|
+
changed at runtime updates without a reload. When nothing has been uploaded
|
|
47
|
+
— the state every install starts in — it falls back to a generated mark
|
|
48
|
+
rather than ``None``, because omitting the tag sends the browser to
|
|
49
|
+
``/favicon.ico``, which this app does not serve.
|
|
44
50
|
"""
|
|
45
51
|
services = getattr(request.app.state, "branding", None)
|
|
46
52
|
settings = getattr(services, "settings", None)
|
|
47
|
-
|
|
48
|
-
|
|
53
|
+
app_name = getattr(settings, "app_name", "") or _DEFAULT_APP_NAME
|
|
54
|
+
accent = getattr(settings, "primary_color", "") or ""
|
|
55
|
+
favicon_url = getattr(services, "favicon_url", None) or default_favicon_data_uri(
|
|
56
|
+
app_name, accent
|
|
57
|
+
)
|
|
49
58
|
return {
|
|
50
|
-
"app_name":
|
|
51
|
-
"theme_color":
|
|
52
|
-
"favicon_url":
|
|
59
|
+
"app_name": app_name,
|
|
60
|
+
"theme_color": accent or None,
|
|
61
|
+
"favicon_url": favicon_url,
|
|
53
62
|
}
|
|
54
63
|
|
|
55
64
|
|
|
56
|
-
# Built assets are served from the "/static" mount under "dist/", so production
|
|
57
|
-
# asset URLs are prefixed with "static/dist".
|
|
58
|
-
_ASSETS_PREFIX = "static/dist"
|
|
59
|
-
_VITE_MANIFEST_RELPATH = Path("static") / "dist" / ".vite" / "manifest.json"
|
|
60
|
-
|
|
61
|
-
|
|
62
65
|
def _prod_manifest_path(project_root: Path) -> str:
|
|
63
|
-
"""
|
|
64
|
-
|
|
65
|
-
fastapi-inertia looks the entry up by ``f"{root_directory}/{entrypoint}"``
|
|
66
|
-
(here ``"./main.tsx"``), but Vite keys its manifest by the entry's path
|
|
67
|
-
relative to the Vite root (``"main.tsx"``) — so the raw Vite manifest would
|
|
68
|
-
``KeyError``. Read it, re-key the ``isEntry`` chunk under the key
|
|
69
|
-
fastapi-inertia expects, and write the normalized copy beside the build
|
|
70
|
-
output (falling back to a temp file if that dir is read-only). Returns ``""``
|
|
71
|
-
when no built manifest exists, leaving production assets unconfigured rather
|
|
72
|
-
than crashing at import time.
|
|
73
|
-
"""
|
|
66
|
+
"""The Vite manifest to read in production, or ``""`` when none is built."""
|
|
74
67
|
candidates = [
|
|
75
68
|
project_root / "host" / _VITE_MANIFEST_RELPATH,
|
|
76
69
|
project_root / _VITE_MANIFEST_RELPATH,
|
|
77
70
|
]
|
|
78
|
-
|
|
79
|
-
if
|
|
71
|
+
found = next((p for p in candidates if p.is_file()), None)
|
|
72
|
+
if found is None:
|
|
80
73
|
logger.warning(
|
|
81
74
|
"Production Vite manifest not found (looked in %s)", [str(c) for c in candidates]
|
|
82
75
|
)
|
|
83
76
|
return ""
|
|
84
|
-
|
|
85
|
-
data = json.loads(vite_manifest.read_text())
|
|
86
|
-
expected_key = f"{_ROOT_DIRECTORY}/{_ENTRYPOINT_FILENAME}"
|
|
87
|
-
if expected_key not in data:
|
|
88
|
-
entry = next((v for v in data.values() if v.get("isEntry")), None)
|
|
89
|
-
if entry is None:
|
|
90
|
-
# No entry to re-key: degrade gracefully (same as no-manifest)
|
|
91
|
-
# rather than returning a path that KeyErrors at render time.
|
|
92
|
-
logger.warning("No isEntry chunk in Vite manifest %s", vite_manifest)
|
|
93
|
-
return ""
|
|
94
|
-
data = {**data, expected_key: entry}
|
|
95
|
-
out = vite_manifest.parent / "inertia-manifest.json"
|
|
96
|
-
try:
|
|
97
|
-
out.write_text(json.dumps(data))
|
|
98
|
-
except OSError:
|
|
99
|
-
# Build dir read-only (e.g. immutable container layer): fall back to
|
|
100
|
-
# a temp file keyed by the source manifest path so multiple apps on
|
|
101
|
-
# one host don't clobber each other's normalized manifests.
|
|
102
|
-
digest = hashlib.sha1(str(vite_manifest).encode()).hexdigest()[:12]
|
|
103
|
-
out = Path(tempfile.gettempdir()) / f"sm-inertia-manifest-{digest}.json"
|
|
104
|
-
out.write_text(json.dumps(data))
|
|
105
|
-
return str(out)
|
|
106
|
-
except Exception:
|
|
107
|
-
logger.exception("Failed to prepare production Inertia manifest from %s", vite_manifest)
|
|
108
|
-
return ""
|
|
77
|
+
return str(found)
|
|
109
78
|
|
|
110
79
|
|
|
111
80
|
def setup_inertia(
|
|
@@ -114,7 +83,7 @@ def setup_inertia(
|
|
|
114
83
|
modules: list,
|
|
115
84
|
project_root: Path,
|
|
116
85
|
) -> InertiaConfig | None:
|
|
117
|
-
"""Configure
|
|
86
|
+
"""Configure the Inertia adapter and attach the dependency factory to app.state.
|
|
118
87
|
|
|
119
88
|
Two host layouts are supported: ``host/templates`` (the framework's
|
|
120
89
|
own host package) and ``templates`` at the project root (what
|
|
@@ -157,14 +126,9 @@ def setup_inertia(
|
|
|
157
126
|
# Expose branding metadata to the root template (pre-hydration head tags).
|
|
158
127
|
templates.env.globals["branding_head"] = branding_head
|
|
159
128
|
|
|
160
|
-
#
|
|
161
|
-
#
|
|
162
|
-
#
|
|
163
|
-
# as text/html, breaking module loading. Normalize:
|
|
164
|
-
# * `development`/`testing` → keep the dev-server path (Vite serves
|
|
165
|
-
# /main.tsx directly).
|
|
166
|
-
# * Anything else (staging, production, qa, ...) → use the production
|
|
167
|
-
# manifest so built assets are referenced.
|
|
129
|
+
# The adapter only switches to the asset manifest when environment equals
|
|
130
|
+
# the literal "production"; normalise anything non-dev (staging, qa, ...)
|
|
131
|
+
# to that so built assets are referenced rather than a Vite dev url.
|
|
168
132
|
from simple_module_core.environments import NON_PROD_ENVIRONMENTS
|
|
169
133
|
|
|
170
134
|
use_dev_server = settings.environment in NON_PROD_ENVIRONMENTS
|
|
@@ -174,8 +138,6 @@ def setup_inertia(
|
|
|
174
138
|
environment=inertia_environment,
|
|
175
139
|
version=_INERTIA_VERSION,
|
|
176
140
|
dev_url=settings.vite_dev_url if use_dev_server else "",
|
|
177
|
-
# Production reads built assets from the Vite manifest; dev serves them
|
|
178
|
-
# from the Vite dev server, so these only matter when not use_dev_server.
|
|
179
141
|
manifest_json_path="" if use_dev_server else _prod_manifest_path(project_root),
|
|
180
142
|
assets_prefix="" if use_dev_server else _ASSETS_PREFIX,
|
|
181
143
|
templates=templates,
|
|
@@ -184,10 +146,5 @@ def setup_inertia(
|
|
|
184
146
|
root_directory=_ROOT_DIRECTORY,
|
|
185
147
|
use_flash_errors=True,
|
|
186
148
|
)
|
|
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))
|
|
192
|
-
app.state.inertia_dependency = inertia_dep
|
|
149
|
+
app.state.inertia_dependency = inertia_dependency_factory(inertia_config)
|
|
193
150
|
return inertia_config
|
|
@@ -14,13 +14,13 @@ from typing import TYPE_CHECKING
|
|
|
14
14
|
from fastapi import FastAPI
|
|
15
15
|
from fastapi.exceptions import RequestValidationError
|
|
16
16
|
from fastapi.staticfiles import StaticFiles
|
|
17
|
-
from inertia import (
|
|
18
|
-
InertiaVersionConflictException,
|
|
19
|
-
inertia_version_conflict_exception_handler,
|
|
20
|
-
)
|
|
21
17
|
from simple_module_core.diagnostics import Diagnostic, DiagnosticLevel
|
|
22
18
|
from simple_module_core.exceptions import NotFoundError
|
|
23
19
|
from simple_module_db import CommitBeforeResponseMiddleware
|
|
20
|
+
from simple_module_inertia import (
|
|
21
|
+
InertiaVersionConflictException,
|
|
22
|
+
inertia_version_conflict_exception_handler,
|
|
23
|
+
)
|
|
24
24
|
from starlette.exceptions import HTTPException
|
|
25
25
|
from starlette.middleware.gzip import GZipMiddleware
|
|
26
26
|
from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware
|
|
@@ -145,6 +145,10 @@ def install_middleware(
|
|
|
145
145
|
)
|
|
146
146
|
if settings.multi_tenant:
|
|
147
147
|
app.add_middleware(TenantMiddleware, header=settings.tenant_header or None)
|
|
148
|
+
elif settings.default_tenant:
|
|
149
|
+
# Single-tenant host with tenant-scoped tables: every request is the
|
|
150
|
+
# one tenant (#359). Never used with multi_tenant — no shared fallback.
|
|
151
|
+
app.add_middleware(TenantMiddleware, fixed=settings.default_tenant)
|
|
148
152
|
for mod in modules:
|
|
149
153
|
mod.register_middleware(app)
|
|
150
154
|
app.add_middleware(SessionMiddleware, secret_key=settings.secret_key)
|
|
@@ -167,8 +171,10 @@ def install_middleware(
|
|
|
167
171
|
app.add_middleware(RequestLoggingMiddleware)
|
|
168
172
|
app.add_middleware(CorrelationIdMiddleware)
|
|
169
173
|
# Outermost: rewrite scheme/client from X-Forwarded-* before anything else
|
|
170
|
-
# reads them, so request logs see the real client IP
|
|
171
|
-
#
|
|
174
|
+
# reads them, so request logs see the real client IP rather than the
|
|
175
|
+
# proxy's. Inertia no longer needs this: its page url is
|
|
176
|
+
# root-relative, so pushState can't see a cross-scheme url either way (it
|
|
177
|
+
# used to throw a SecurityError on every page — GH #223). Gated on an
|
|
172
178
|
# explicit trust setting — never trust forwarded headers by default.
|
|
173
179
|
if settings.trusted_proxy:
|
|
174
180
|
app.add_middleware(ProxyHeadersMiddleware, trusted_hosts=settings.trusted_proxy)
|
|
@@ -19,6 +19,7 @@ def run_module_registrations(
|
|
|
19
19
|
*,
|
|
20
20
|
app: FastAPI,
|
|
21
21
|
event_bus,
|
|
22
|
+
invalidation_bus,
|
|
22
23
|
menu_registry,
|
|
23
24
|
perm_registry,
|
|
24
25
|
ff_registry,
|
|
@@ -42,6 +43,7 @@ def run_module_registrations(
|
|
|
42
43
|
mod.register_permissions(perm_registry)
|
|
43
44
|
mod.register_feature_flags(ff_registry)
|
|
44
45
|
dispatch_event_handlers(mod, event_bus, app)
|
|
46
|
+
mod.register_invalidations(invalidation_bus, app)
|
|
45
47
|
health_registry.set_owner(mod.meta.name)
|
|
46
48
|
mod.register_health_checks(health_registry)
|
|
47
49
|
mod.register_public_routes(public_route_registry)
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Tenant-context middleware: which tenant a request acts for."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Awaitable, Callable
|
|
6
|
+
|
|
7
|
+
from simple_module_db import current_tenant_id, is_valid_tenant_id
|
|
8
|
+
from starlette.datastructures import Headers
|
|
9
|
+
from starlette.requests import Request
|
|
10
|
+
from starlette.types import ASGIApp, Receive, Scope, Send
|
|
11
|
+
|
|
12
|
+
_SCOPE_HTTP = "http"
|
|
13
|
+
|
|
14
|
+
TENANT_HEADER = "X-Tenant-ID"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
TenantResolver = Callable[[Request], Awaitable[str | None]]
|
|
18
|
+
"""Module-owned tenant resolution, registered as ``app.state.tenant_resolver``.
|
|
19
|
+
|
|
20
|
+
Returns the tenant the request acts for, or ``None``. It owns *every* source —
|
|
21
|
+
membership, session, subdomain, header — so it is also where each is
|
|
22
|
+
validated. Without one the middleware falls back to the principal's
|
|
23
|
+
``tenant_id`` claim."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class TenantMiddleware:
|
|
27
|
+
"""Establish the request's tenant context.
|
|
28
|
+
|
|
29
|
+
Sets the ``current_tenant_id`` context var so that DB queries on
|
|
30
|
+
:class:`~simple_module_db.mixins.MultiTenantMixin` models are
|
|
31
|
+
automatically filtered, and new objects get ``tenant_id`` populated.
|
|
32
|
+
|
|
33
|
+
Also stores the resolved value on ``request.state.tenant_id``.
|
|
34
|
+
|
|
35
|
+
Resolution:
|
|
36
|
+
|
|
37
|
+
1. If a module registered ``app.state.tenant_resolver`` (the ``tenants``
|
|
38
|
+
module does), its answer is final — including ``None``.
|
|
39
|
+
2. Otherwise the authenticated user's ``tenant_id`` attribute (from the
|
|
40
|
+
auth token claims).
|
|
41
|
+
3. Otherwise, for **anonymous** requests only, the configured header —
|
|
42
|
+
useful for API clients and tests. Pass ``header=None`` (the default)
|
|
43
|
+
to disable it. An authenticated user is never allowed to pick a tenant
|
|
44
|
+
by header: a user with no tenant of their own would otherwise be able
|
|
45
|
+
to name anyone's.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__(
|
|
49
|
+
self, app: ASGIApp, *, header: str | None = None, fixed: str | None = None
|
|
50
|
+
) -> None:
|
|
51
|
+
self.app = app
|
|
52
|
+
self.header = header
|
|
53
|
+
# Single-tenant hosts: one tenant for every request, nothing resolved.
|
|
54
|
+
self.fixed = fixed
|
|
55
|
+
|
|
56
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
57
|
+
if scope["type"] != _SCOPE_HTTP:
|
|
58
|
+
await self.app(scope, receive, send)
|
|
59
|
+
return
|
|
60
|
+
|
|
61
|
+
request = Request(scope)
|
|
62
|
+
tenant_id = await self._resolve(request, scope)
|
|
63
|
+
request.state.tenant_id = tenant_id
|
|
64
|
+
|
|
65
|
+
if tenant_id is not None:
|
|
66
|
+
token = current_tenant_id.set(tenant_id)
|
|
67
|
+
try:
|
|
68
|
+
await self.app(scope, receive, send)
|
|
69
|
+
finally:
|
|
70
|
+
current_tenant_id.reset(token)
|
|
71
|
+
return
|
|
72
|
+
|
|
73
|
+
await self.app(scope, receive, send)
|
|
74
|
+
|
|
75
|
+
async def _resolve(self, request: Request, scope: Scope) -> str | None:
|
|
76
|
+
if self.fixed is not None:
|
|
77
|
+
return self.fixed
|
|
78
|
+
app = scope.get("app")
|
|
79
|
+
resolver: TenantResolver | None = getattr(
|
|
80
|
+
getattr(app, "state", None), "tenant_resolver", None
|
|
81
|
+
)
|
|
82
|
+
if resolver is not None:
|
|
83
|
+
return await resolver(request)
|
|
84
|
+
|
|
85
|
+
user = getattr(request.state, "user", None)
|
|
86
|
+
if user is not None:
|
|
87
|
+
return getattr(user, "tenant_id", None)
|
|
88
|
+
|
|
89
|
+
if self.header:
|
|
90
|
+
value = Headers(scope=scope).get(self.header)
|
|
91
|
+
# Unvalidated, an over-long value is a 500 on the first stamped
|
|
92
|
+
# write (VARCHAR(50)) and any junk becomes a tenant name (#366).
|
|
93
|
+
return value if is_valid_tenant_id(value) else None
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
__all__ = ["TENANT_HEADER", "TenantMiddleware", "TenantResolver"]
|