astris-python 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- astris/__init__.py +7 -0
- astris/assets/favicon.ico +0 -0
- astris/auth/__init__.py +29 -0
- astris/auth/installer.py +512 -0
- astris/auth/session.py +241 -0
- astris/cli.py +421 -0
- astris/config.py +50 -0
- astris/database/__init__.py +28 -0
- astris/database/migrations.py +259 -0
- astris/database/session.py +108 -0
- astris/http/__init__.py +25 -0
- astris/http/static.py +31 -0
- astris/inertia/__init__.py +8 -0
- astris/inertia/exceptions.py +133 -0
- astris/inertia/response.py +99 -0
- astris/inertia/shared.py +176 -0
- astris/inertia/vite.py +89 -0
- astris/installer.py +595 -0
- astris/kernel.py +230 -0
- astris/py.typed +0 -0
- astris/routing/__init__.py +31 -0
- astris/routing/router.py +38 -0
- astris/security/__init__.py +3 -0
- astris/security/csrf.py +100 -0
- astris_python-0.1.0.dist-info/METADATA +241 -0
- astris_python-0.1.0.dist-info/RECORD +29 -0
- astris_python-0.1.0.dist-info/WHEEL +4 -0
- astris_python-0.1.0.dist-info/entry_points.txt +5 -0
- astris_python-0.1.0.dist-info/licenses/LICENSE +21 -0
astris/config.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
from functools import lru_cache
|
|
2
|
+
from typing import Literal
|
|
3
|
+
|
|
4
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Settings(BaseSettings):
|
|
8
|
+
"""Centralized application and framework configuration."""
|
|
9
|
+
|
|
10
|
+
model_config = SettingsConfigDict(
|
|
11
|
+
env_file=".env",
|
|
12
|
+
env_file_encoding="utf-8",
|
|
13
|
+
extra="ignore",
|
|
14
|
+
case_sensitive=False,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
# Core Application
|
|
18
|
+
app_name: str = "Astris Application"
|
|
19
|
+
app_env: str = "local"
|
|
20
|
+
app_debug: bool = True
|
|
21
|
+
app_key: str = ""
|
|
22
|
+
|
|
23
|
+
# Database
|
|
24
|
+
database_url: str = "sqlite:///database/app.db"
|
|
25
|
+
db_echo: bool = False
|
|
26
|
+
auto_create_tables: bool = True
|
|
27
|
+
|
|
28
|
+
# Sessions & Security
|
|
29
|
+
session_cookie_name: str = "astris_session"
|
|
30
|
+
session_max_age: int | None = 14 * 24 * 60 * 60 # 14 days
|
|
31
|
+
session_https_only: bool = False
|
|
32
|
+
session_same_site: Literal["lax", "strict", "none"] = "lax"
|
|
33
|
+
|
|
34
|
+
# CORS & CSRF
|
|
35
|
+
cors_origins: list[str] = [
|
|
36
|
+
"http://localhost:5173",
|
|
37
|
+
"http://127.0.0.1:5173",
|
|
38
|
+
]
|
|
39
|
+
enable_csrf: bool = True
|
|
40
|
+
csrf_exempt_paths: list[str] = []
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@lru_cache
|
|
44
|
+
def get_settings() -> Settings:
|
|
45
|
+
"""Return the cached application settings instance."""
|
|
46
|
+
return Settings()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# Global settings singleton
|
|
50
|
+
settings = get_settings()
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from sqlmodel import (
|
|
2
|
+
Field,
|
|
3
|
+
Relationship,
|
|
4
|
+
Session,
|
|
5
|
+
SQLModel,
|
|
6
|
+
col,
|
|
7
|
+
select,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
from astris.database.session import (
|
|
11
|
+
Database,
|
|
12
|
+
DatabaseSession,
|
|
13
|
+
db,
|
|
14
|
+
get_session,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"Database",
|
|
19
|
+
"DatabaseSession",
|
|
20
|
+
"Field",
|
|
21
|
+
"Relationship",
|
|
22
|
+
"SQLModel",
|
|
23
|
+
"Session",
|
|
24
|
+
"col",
|
|
25
|
+
"db",
|
|
26
|
+
"get_session",
|
|
27
|
+
"select",
|
|
28
|
+
]
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import importlib
|
|
2
|
+
import pkgutil
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from alembic import command
|
|
8
|
+
from alembic.config import Config
|
|
9
|
+
from alembic.runtime.migration import MigrationContext
|
|
10
|
+
from alembic.script import ScriptDirectory
|
|
11
|
+
|
|
12
|
+
from astris.database.session import db
|
|
13
|
+
|
|
14
|
+
ENV_PY_TEMPLATE = """from astris.database.migrations import run_env
|
|
15
|
+
|
|
16
|
+
run_env()
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
SCRIPT_MAKO_TEMPLATE = '''"""${message}
|
|
20
|
+
|
|
21
|
+
Revision ID: ${up_revision}
|
|
22
|
+
Revises: ${down_revision | comma,n}
|
|
23
|
+
Create Date: ${create_date}
|
|
24
|
+
|
|
25
|
+
"""
|
|
26
|
+
from collections.abc import Sequence
|
|
27
|
+
|
|
28
|
+
from alembic import op
|
|
29
|
+
import sqlalchemy as sa
|
|
30
|
+
import sqlmodel
|
|
31
|
+
${imports if imports else ""}
|
|
32
|
+
|
|
33
|
+
# revision identifiers, used by Alembic.
|
|
34
|
+
revision: str = ${repr(up_revision)}
|
|
35
|
+
down_revision: str | Sequence[str] | None = ${repr(down_revision)}
|
|
36
|
+
branch_labels: str | Sequence[str] | None = ${repr(branch_labels)}
|
|
37
|
+
depends_on: str | Sequence[str] | None = ${repr(depends_on)}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def upgrade() -> None:
|
|
41
|
+
"""Upgrade schema."""
|
|
42
|
+
${upgrades if upgrades else "pass"}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def downgrade() -> None:
|
|
46
|
+
"""Downgrade schema."""
|
|
47
|
+
${downgrades if downgrades else "pass"}
|
|
48
|
+
'''
|
|
49
|
+
|
|
50
|
+
ALEMBIC_INI_TEMPLATE = """[alembic]
|
|
51
|
+
script_location = database/migrations
|
|
52
|
+
prepend_sys_path = .
|
|
53
|
+
version_path_separator = os
|
|
54
|
+
|
|
55
|
+
[loggers]
|
|
56
|
+
keys = root,sqlalchemy,alembic
|
|
57
|
+
|
|
58
|
+
[handlers]
|
|
59
|
+
keys = console
|
|
60
|
+
|
|
61
|
+
[formatters]
|
|
62
|
+
keys = generic
|
|
63
|
+
|
|
64
|
+
[logger_root]
|
|
65
|
+
level = WARN
|
|
66
|
+
handlers = console
|
|
67
|
+
qualname =
|
|
68
|
+
|
|
69
|
+
[logger_sqlalchemy]
|
|
70
|
+
level = WARN
|
|
71
|
+
handlers =
|
|
72
|
+
qualname = sqlalchemy.engine
|
|
73
|
+
|
|
74
|
+
[logger_alembic]
|
|
75
|
+
level = INFO
|
|
76
|
+
handlers =
|
|
77
|
+
qualname = alembic
|
|
78
|
+
|
|
79
|
+
[handler_console]
|
|
80
|
+
class = StreamHandler
|
|
81
|
+
args = (sys.stderr,)
|
|
82
|
+
level = NOTSET
|
|
83
|
+
formatter = generic
|
|
84
|
+
|
|
85
|
+
[formatter_generic]
|
|
86
|
+
format = %(levelname)-5.5s [%(name)s] %(message)s
|
|
87
|
+
datefmt = %H:%M:%S
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def discover_models(base_path: Path | None = None) -> None:
|
|
92
|
+
"""Discover and import all domain model files to populate SQLModel.metadata."""
|
|
93
|
+
root = base_path or Path.cwd()
|
|
94
|
+
root_str = str(root)
|
|
95
|
+
if root_str not in sys.path:
|
|
96
|
+
sys.path.insert(0, root_str)
|
|
97
|
+
|
|
98
|
+
modules_dir = root / "app" / "modules"
|
|
99
|
+
if not modules_dir.exists():
|
|
100
|
+
return
|
|
101
|
+
|
|
102
|
+
for _, modname, ispkg in pkgutil.walk_packages(
|
|
103
|
+
[str(modules_dir)], prefix="app.modules."
|
|
104
|
+
):
|
|
105
|
+
if ispkg:
|
|
106
|
+
continue
|
|
107
|
+
last_part = modname.split(".")[-1]
|
|
108
|
+
if last_part.endswith(("_model", "_models")) or last_part in (
|
|
109
|
+
"models",
|
|
110
|
+
"model",
|
|
111
|
+
):
|
|
112
|
+
try:
|
|
113
|
+
importlib.import_module(modname)
|
|
114
|
+
except ImportError:
|
|
115
|
+
pass
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def ensure_migration_setup(base_path: Path | None = None) -> tuple[Path, Path]:
|
|
119
|
+
"""Ensure alembic.ini and database/migrations/ directory structure exist."""
|
|
120
|
+
root = base_path or Path.cwd()
|
|
121
|
+
ini_path = root / "alembic.ini"
|
|
122
|
+
migrations_dir = root / "database" / "migrations"
|
|
123
|
+
versions_dir = migrations_dir / "versions"
|
|
124
|
+
|
|
125
|
+
migrations_dir.mkdir(parents=True, exist_ok=True)
|
|
126
|
+
versions_dir.mkdir(parents=True, exist_ok=True)
|
|
127
|
+
|
|
128
|
+
if not ini_path.exists():
|
|
129
|
+
ini_path.write_text(ALEMBIC_INI_TEMPLATE, encoding="utf-8")
|
|
130
|
+
|
|
131
|
+
env_path = migrations_dir / "env.py"
|
|
132
|
+
if not env_path.exists():
|
|
133
|
+
env_path.write_text(ENV_PY_TEMPLATE, encoding="utf-8")
|
|
134
|
+
|
|
135
|
+
mako_path = migrations_dir / "script.py.mako"
|
|
136
|
+
if not mako_path.exists():
|
|
137
|
+
mako_path.write_text(SCRIPT_MAKO_TEMPLATE, encoding="utf-8")
|
|
138
|
+
|
|
139
|
+
return ini_path, migrations_dir
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def get_alembic_config(base_path: Path | None = None) -> Config:
|
|
143
|
+
"""Create and configure an Alembic Config object."""
|
|
144
|
+
root = base_path or Path.cwd()
|
|
145
|
+
ini_path, migrations_dir = ensure_migration_setup(root)
|
|
146
|
+
|
|
147
|
+
discover_models(root)
|
|
148
|
+
|
|
149
|
+
cfg = Config(str(ini_path))
|
|
150
|
+
cfg.set_main_option("script_location", str(migrations_dir))
|
|
151
|
+
cfg.set_main_option("sqlalchemy.url", db.url)
|
|
152
|
+
return cfg
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def create_migration(
|
|
156
|
+
message: str,
|
|
157
|
+
autogenerate: bool = True,
|
|
158
|
+
base_path: Path | None = None,
|
|
159
|
+
) -> None:
|
|
160
|
+
"""Generate a new database migration file."""
|
|
161
|
+
cfg = get_alembic_config(base_path)
|
|
162
|
+
command.revision(cfg, message=message, autogenerate=autogenerate)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def run_migrations(
|
|
166
|
+
revision: str = "head",
|
|
167
|
+
base_path: Path | None = None,
|
|
168
|
+
) -> None:
|
|
169
|
+
"""Apply database migrations up to the target revision (default: 'head')."""
|
|
170
|
+
cfg = get_alembic_config(base_path)
|
|
171
|
+
command.upgrade(cfg, revision)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def rollback_migrations(
|
|
175
|
+
revision: str = "-1",
|
|
176
|
+
base_path: Path | None = None,
|
|
177
|
+
) -> None:
|
|
178
|
+
"""Roll back database migrations down to the target revision (default: '-1')."""
|
|
179
|
+
cfg = get_alembic_config(base_path)
|
|
180
|
+
command.downgrade(cfg, revision)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def get_migration_status(base_path: Path | None = None) -> dict[str, Any]:
|
|
184
|
+
"""Retrieve the current migration revision and available heads."""
|
|
185
|
+
cfg = get_alembic_config(base_path)
|
|
186
|
+
script_dir = ScriptDirectory.from_config(cfg)
|
|
187
|
+
with db.engine.connect() as conn:
|
|
188
|
+
context = MigrationContext.configure(conn)
|
|
189
|
+
current_revs = context.get_current_heads()
|
|
190
|
+
heads = script_dir.get_heads()
|
|
191
|
+
|
|
192
|
+
return {
|
|
193
|
+
"current_revisions": list(current_revs),
|
|
194
|
+
"heads": list(heads),
|
|
195
|
+
"is_up_to_date": set(current_revs) == set(heads),
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def run_env(
|
|
200
|
+
target_metadata: Any = None,
|
|
201
|
+
base_path: Path | None = None,
|
|
202
|
+
) -> None:
|
|
203
|
+
"""Run migrations in offline or online mode.
|
|
204
|
+
|
|
205
|
+
Executed by database/migrations/env.py during Alembic migration runs.
|
|
206
|
+
"""
|
|
207
|
+
from logging.config import fileConfig
|
|
208
|
+
|
|
209
|
+
from alembic import context
|
|
210
|
+
from sqlalchemy import engine_from_config, pool
|
|
211
|
+
|
|
212
|
+
from astris.database import SQLModel
|
|
213
|
+
|
|
214
|
+
root = base_path or Path.cwd()
|
|
215
|
+
root_str = str(root)
|
|
216
|
+
if root_str not in sys.path:
|
|
217
|
+
sys.path.insert(0, root_str)
|
|
218
|
+
|
|
219
|
+
discover_models(root)
|
|
220
|
+
|
|
221
|
+
config = context.config
|
|
222
|
+
|
|
223
|
+
if config.config_file_name is not None:
|
|
224
|
+
fileConfig(config.config_file_name)
|
|
225
|
+
|
|
226
|
+
metadata = target_metadata if target_metadata is not None else SQLModel.metadata
|
|
227
|
+
|
|
228
|
+
if context.is_offline_mode():
|
|
229
|
+
url = config.get_main_option("sqlalchemy.url")
|
|
230
|
+
is_sqlite = url and url.startswith("sqlite")
|
|
231
|
+
context.configure(
|
|
232
|
+
url=url,
|
|
233
|
+
target_metadata=metadata,
|
|
234
|
+
literal_binds=True,
|
|
235
|
+
dialect_opts={"paramstyle": "named"},
|
|
236
|
+
render_as_batch=bool(is_sqlite),
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
with context.begin_transaction():
|
|
240
|
+
context.run_migrations()
|
|
241
|
+
else:
|
|
242
|
+
connectable = config.attributes.get("connection", None)
|
|
243
|
+
if connectable is None:
|
|
244
|
+
connectable = engine_from_config(
|
|
245
|
+
config.get_section(config.config_ini_section, {}),
|
|
246
|
+
prefix="sqlalchemy.",
|
|
247
|
+
poolclass=pool.NullPool,
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
with connectable.connect() as connection:
|
|
251
|
+
is_sqlite = connection.dialect.name == "sqlite"
|
|
252
|
+
context.configure(
|
|
253
|
+
connection=connection,
|
|
254
|
+
target_metadata=metadata,
|
|
255
|
+
render_as_batch=is_sqlite,
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
with context.begin_transaction():
|
|
259
|
+
context.run_migrations()
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
from collections.abc import Generator
|
|
2
|
+
from contextlib import contextmanager
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Annotated, Any
|
|
5
|
+
|
|
6
|
+
from fastapi import Depends
|
|
7
|
+
from sqlalchemy import Engine
|
|
8
|
+
from sqlmodel import Session, SQLModel, create_engine
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Database:
|
|
12
|
+
"""Manages the SQLModel engine and session lifecycle."""
|
|
13
|
+
|
|
14
|
+
def __init__(
|
|
15
|
+
self,
|
|
16
|
+
url: str | None = None,
|
|
17
|
+
echo: bool = False,
|
|
18
|
+
base_path: Path | None = None,
|
|
19
|
+
**engine_kwargs: Any,
|
|
20
|
+
) -> None:
|
|
21
|
+
self._url = url
|
|
22
|
+
self._echo = echo
|
|
23
|
+
self._base_path = base_path or Path.cwd()
|
|
24
|
+
self._engine_kwargs = engine_kwargs
|
|
25
|
+
self._engine: Engine | None = None
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def url(self) -> str:
|
|
29
|
+
if self._url:
|
|
30
|
+
return self._url
|
|
31
|
+
from astris.config import get_settings
|
|
32
|
+
|
|
33
|
+
config_url = get_settings().database_url
|
|
34
|
+
if config_url:
|
|
35
|
+
return config_url
|
|
36
|
+
|
|
37
|
+
# Default to SQLite at database/app.db
|
|
38
|
+
db_dir = self._base_path / "database"
|
|
39
|
+
db_dir.mkdir(parents=True, exist_ok=True)
|
|
40
|
+
db_path = db_dir / "app.db"
|
|
41
|
+
return f"sqlite:///{db_path}"
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def engine(self) -> Engine:
|
|
45
|
+
if self._engine is not None:
|
|
46
|
+
return self._engine
|
|
47
|
+
|
|
48
|
+
connect_args: dict[str, Any] = {}
|
|
49
|
+
if self.url.startswith("sqlite"):
|
|
50
|
+
connect_args["check_same_thread"] = False
|
|
51
|
+
|
|
52
|
+
kwargs = {**self._engine_kwargs}
|
|
53
|
+
if connect_args:
|
|
54
|
+
kwargs["connect_args"] = {
|
|
55
|
+
**connect_args,
|
|
56
|
+
**kwargs.get("connect_args", {}),
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
engine = create_engine(self.url, echo=self._echo, **kwargs)
|
|
60
|
+
self._engine = engine
|
|
61
|
+
return engine
|
|
62
|
+
|
|
63
|
+
def configure(
|
|
64
|
+
self,
|
|
65
|
+
url: str | None = None,
|
|
66
|
+
echo: bool = False,
|
|
67
|
+
base_path: Path | None = None,
|
|
68
|
+
**engine_kwargs: Any,
|
|
69
|
+
) -> None:
|
|
70
|
+
"""Reconfigure database settings and reset the engine."""
|
|
71
|
+
self._url = url
|
|
72
|
+
self._echo = echo
|
|
73
|
+
if base_path:
|
|
74
|
+
self._base_path = base_path
|
|
75
|
+
self._engine_kwargs = engine_kwargs
|
|
76
|
+
self._engine = None
|
|
77
|
+
|
|
78
|
+
def create_all(self) -> None:
|
|
79
|
+
"""Create all registered SQLModel tables."""
|
|
80
|
+
SQLModel.metadata.create_all(self.engine)
|
|
81
|
+
|
|
82
|
+
def drop_all(self) -> None:
|
|
83
|
+
"""Drop all registered SQLModel tables."""
|
|
84
|
+
SQLModel.metadata.drop_all(self.engine)
|
|
85
|
+
|
|
86
|
+
def get_session(self) -> Generator[Session]:
|
|
87
|
+
"""FastAPI dependency yielding a Session."""
|
|
88
|
+
with Session(self.engine) as session:
|
|
89
|
+
yield session
|
|
90
|
+
|
|
91
|
+
@contextmanager
|
|
92
|
+
def session(self) -> Generator[Session]:
|
|
93
|
+
"""Context manager yielding a Session for background tasks, CLI, and scripts."""
|
|
94
|
+
with Session(self.engine) as session:
|
|
95
|
+
yield session
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
# Global default database instance
|
|
99
|
+
db = Database()
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def get_session() -> Generator[Session]:
|
|
103
|
+
"""Astris dependency that yields a database session."""
|
|
104
|
+
yield from db.get_session()
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# First-class dependency injection alias for route & controller handlers
|
|
108
|
+
DatabaseSession = Annotated[Session, Depends(get_session)]
|
astris/http/__init__.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from fastapi import BackgroundTasks, HTTPException, status
|
|
2
|
+
from fastapi.requests import Request
|
|
3
|
+
from fastapi.responses import (
|
|
4
|
+
FileResponse,
|
|
5
|
+
HTMLResponse,
|
|
6
|
+
JSONResponse,
|
|
7
|
+
PlainTextResponse,
|
|
8
|
+
RedirectResponse,
|
|
9
|
+
Response,
|
|
10
|
+
StreamingResponse,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"BackgroundTasks",
|
|
15
|
+
"FileResponse",
|
|
16
|
+
"HTMLResponse",
|
|
17
|
+
"HTTPException",
|
|
18
|
+
"JSONResponse",
|
|
19
|
+
"PlainTextResponse",
|
|
20
|
+
"RedirectResponse",
|
|
21
|
+
"Request",
|
|
22
|
+
"Response",
|
|
23
|
+
"StreamingResponse",
|
|
24
|
+
"status",
|
|
25
|
+
]
|
astris/http/static.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
|
|
4
|
+
from starlette.requests import Request
|
|
5
|
+
from starlette.responses import FileResponse, Response
|
|
6
|
+
from starlette.types import ASGIApp
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class PublicStaticMiddleware(BaseHTTPMiddleware):
|
|
10
|
+
"""Serve physical static files directly from the public/ directory."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, app: ASGIApp, public_dir: Path) -> None:
|
|
13
|
+
super().__init__(app)
|
|
14
|
+
self.public_dir = public_dir
|
|
15
|
+
|
|
16
|
+
async def dispatch(
|
|
17
|
+
self, request: Request, call_next: RequestResponseEndpoint
|
|
18
|
+
) -> Response:
|
|
19
|
+
if request.method in ("GET", "HEAD"):
|
|
20
|
+
raw_path = request.url.path.lstrip("/")
|
|
21
|
+
if raw_path and not raw_path.startswith("build/"):
|
|
22
|
+
file_path = (self.public_dir / raw_path).resolve()
|
|
23
|
+
try:
|
|
24
|
+
if file_path.is_file() and file_path.is_relative_to(
|
|
25
|
+
self.public_dir
|
|
26
|
+
):
|
|
27
|
+
return FileResponse(file_path)
|
|
28
|
+
except (ValueError, OSError):
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
return await call_next(request)
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from typing import Any
|
|
3
|
+
from urllib.parse import quote
|
|
4
|
+
|
|
5
|
+
from fastapi import Request
|
|
6
|
+
from fastapi.exceptions import RequestValidationError
|
|
7
|
+
from pydantic import ValidationError
|
|
8
|
+
from starlette.responses import JSONResponse, RedirectResponse, Response
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def format_validation_errors(
|
|
12
|
+
exc: RequestValidationError | ValidationError,
|
|
13
|
+
) -> dict[str, str]:
|
|
14
|
+
"""Extract a clean, human-readable dictionary of field -> message from Pydantic validation errors."""
|
|
15
|
+
errors: dict[str, str] = {}
|
|
16
|
+
for error in exc.errors():
|
|
17
|
+
# Strip internal FastAPI/Pydantic location qualifiers like 'body', 'query', '__root__'
|
|
18
|
+
raw_loc = error.get("loc")
|
|
19
|
+
loc_parts: list[str] = []
|
|
20
|
+
if isinstance(raw_loc, (tuple, list)):
|
|
21
|
+
for part in raw_loc:
|
|
22
|
+
part_str = f"{part}"
|
|
23
|
+
if part_str not in (
|
|
24
|
+
"body",
|
|
25
|
+
"query",
|
|
26
|
+
"header",
|
|
27
|
+
"cookie",
|
|
28
|
+
"path",
|
|
29
|
+
"__root__",
|
|
30
|
+
):
|
|
31
|
+
loc_parts.append(part_str)
|
|
32
|
+
|
|
33
|
+
field = ".".join(loc_parts) if loc_parts else "non_field_errors"
|
|
34
|
+
if field not in errors:
|
|
35
|
+
msg = error.get("msg")
|
|
36
|
+
errors[field] = msg if isinstance(msg, str) else "Invalid value"
|
|
37
|
+
return errors
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def create_inertia_validation_response(
|
|
41
|
+
request: Request,
|
|
42
|
+
errors: dict[str, Any],
|
|
43
|
+
) -> Response:
|
|
44
|
+
"""Create an Inertia-compliant validation error response.
|
|
45
|
+
|
|
46
|
+
Returns a 303 redirect with flashed errors for state-changing form requests,
|
|
47
|
+
or a 422 JSON response with the X-Inertia header.
|
|
48
|
+
"""
|
|
49
|
+
referer = request.headers.get("Referer")
|
|
50
|
+
if referer and request.method.upper() in ("POST", "PUT", "PATCH", "DELETE"):
|
|
51
|
+
if hasattr(request, "session"):
|
|
52
|
+
request.session["_errors"] = errors
|
|
53
|
+
response = RedirectResponse(url=referer, status_code=303)
|
|
54
|
+
response.set_cookie(
|
|
55
|
+
key="_inertia_errors",
|
|
56
|
+
value=quote(json.dumps(errors)),
|
|
57
|
+
path="/",
|
|
58
|
+
httponly=True,
|
|
59
|
+
samesite="lax",
|
|
60
|
+
max_age=10,
|
|
61
|
+
)
|
|
62
|
+
return response
|
|
63
|
+
|
|
64
|
+
return JSONResponse(
|
|
65
|
+
status_code=422,
|
|
66
|
+
content={"errors": errors},
|
|
67
|
+
headers={"X-Inertia": "true"},
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
async def inertia_validation_exception_handler(
|
|
72
|
+
request: Request,
|
|
73
|
+
exc: Exception,
|
|
74
|
+
) -> Response:
|
|
75
|
+
"""Handle validation errors for Inertia requests by returning an Inertia-compliant
|
|
76
|
+
|
|
77
|
+
303 redirect with session errors, or a standard 422 JSON response for API consumers.
|
|
78
|
+
"""
|
|
79
|
+
if isinstance(exc, (RequestValidationError, ValidationError)):
|
|
80
|
+
errors = format_validation_errors(exc)
|
|
81
|
+
detail = exc.errors()
|
|
82
|
+
else:
|
|
83
|
+
errors = {"non_field_errors": "Validation error"}
|
|
84
|
+
detail = [{"msg": "Validation error", "type": "value_error"}]
|
|
85
|
+
|
|
86
|
+
is_inertia = request.headers.get("X-Inertia") == "true"
|
|
87
|
+
|
|
88
|
+
if is_inertia:
|
|
89
|
+
return create_inertia_validation_response(request, errors)
|
|
90
|
+
|
|
91
|
+
return JSONResponse(
|
|
92
|
+
status_code=422,
|
|
93
|
+
content={"detail": detail, "errors": errors},
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
async def inertia_http_exception_handler(
|
|
98
|
+
request: Request,
|
|
99
|
+
exc: Exception,
|
|
100
|
+
) -> Response:
|
|
101
|
+
"""Handle HTTPExceptions for Inertia and standard API requests.
|
|
102
|
+
|
|
103
|
+
Formats 422 unprocessable entity errors as Inertia-compliant 303 redirects or {"errors": {...}}
|
|
104
|
+
responses with the X-Inertia header, so frontend form validation states update seamlessly.
|
|
105
|
+
"""
|
|
106
|
+
is_inertia = request.headers.get("X-Inertia") == "true"
|
|
107
|
+
status_code = getattr(exc, "status_code", 500)
|
|
108
|
+
detail = getattr(exc, "detail", "An error occurred")
|
|
109
|
+
|
|
110
|
+
if is_inertia and status_code == 422:
|
|
111
|
+
if isinstance(detail, dict):
|
|
112
|
+
errors = {str(k): str(v) for k, v in detail.items()}
|
|
113
|
+
elif isinstance(detail, list):
|
|
114
|
+
errors = {}
|
|
115
|
+
for item in detail:
|
|
116
|
+
if isinstance(item, dict):
|
|
117
|
+
loc = item.get("loc", ["non_field_errors"])[-1]
|
|
118
|
+
errors[str(loc)] = str(item.get("msg", "Invalid value"))
|
|
119
|
+
else:
|
|
120
|
+
errors["non_field_errors"] = str(item)
|
|
121
|
+
elif isinstance(detail, str):
|
|
122
|
+
errors = {"non_field_errors": detail}
|
|
123
|
+
else:
|
|
124
|
+
errors = {"non_field_errors": "Validation error"}
|
|
125
|
+
|
|
126
|
+
return create_inertia_validation_response(request, errors)
|
|
127
|
+
|
|
128
|
+
headers = getattr(exc, "headers", None)
|
|
129
|
+
return JSONResponse(
|
|
130
|
+
status_code=status_code,
|
|
131
|
+
content={"detail": detail},
|
|
132
|
+
headers=headers,
|
|
133
|
+
)
|