drfast-api 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.
- drfast/__init__.py +44 -0
- drfast/auth.py +126 -0
- drfast/cli.py +358 -0
- drfast/filters.py +36 -0
- drfast/middleware.py +48 -0
- drfast/models.py +45 -0
- drfast/orm.py +35 -0
- drfast/pagination.py +53 -0
- drfast/permissions.py +54 -0
- drfast/routers.py +42 -0
- drfast/serializers.py +214 -0
- drfast/settings.py +45 -0
- drfast/views.py +230 -0
- drfast_api-0.1.0.dist-info/METADATA +98 -0
- drfast_api-0.1.0.dist-info/RECORD +18 -0
- drfast_api-0.1.0.dist-info/WHEEL +4 -0
- drfast_api-0.1.0.dist-info/entry_points.txt +2 -0
- drfast_api-0.1.0.dist-info/licenses/LICENSE +21 -0
drfast/__init__.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""DRF-inspired primitives for async FastAPI applications."""
|
|
2
|
+
|
|
3
|
+
from drfast.filters import FilterSet
|
|
4
|
+
from drfast.middleware import BaseMiddleware
|
|
5
|
+
from drfast.models import Model, SoftDeleteModel, TimestampedModel
|
|
6
|
+
from drfast.pagination import LimitOffsetPagination
|
|
7
|
+
from drfast.permissions import AllowAny, BasePermission, IsAdminUser, IsAuthenticated
|
|
8
|
+
from drfast.routers import DefaultRouter
|
|
9
|
+
from drfast.serializers import (
|
|
10
|
+
BooleanField,
|
|
11
|
+
CharField,
|
|
12
|
+
EmailField,
|
|
13
|
+
IntegerField,
|
|
14
|
+
ModelSerializer,
|
|
15
|
+
Serializer,
|
|
16
|
+
UUIDField,
|
|
17
|
+
)
|
|
18
|
+
from drfast.settings import DRFastSettings
|
|
19
|
+
from drfast.views import APIView, ModelViewSet
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"APIView",
|
|
23
|
+
"AllowAny",
|
|
24
|
+
"BaseMiddleware",
|
|
25
|
+
"BasePermission",
|
|
26
|
+
"BooleanField",
|
|
27
|
+
"CharField",
|
|
28
|
+
"DRFastSettings",
|
|
29
|
+
"DefaultRouter",
|
|
30
|
+
"EmailField",
|
|
31
|
+
"FilterSet",
|
|
32
|
+
"IntegerField",
|
|
33
|
+
"IsAdminUser",
|
|
34
|
+
"IsAuthenticated",
|
|
35
|
+
"LimitOffsetPagination",
|
|
36
|
+
"Model",
|
|
37
|
+
"ModelSerializer",
|
|
38
|
+
"ModelViewSet",
|
|
39
|
+
"Serializer",
|
|
40
|
+
"SoftDeleteModel",
|
|
41
|
+
"TimestampedModel",
|
|
42
|
+
"UUIDField",
|
|
43
|
+
]
|
|
44
|
+
|
drfast/auth.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Current FastAPI Users integration backed by Tortoise ORM.
|
|
2
|
+
|
|
3
|
+
FastAPI Users no longer ships a Tortoise adapter. This module maintains the
|
|
4
|
+
small persistence bridge while leaving its authentication and security flows
|
|
5
|
+
to FastAPI Users.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import AsyncGenerator, Callable
|
|
11
|
+
from typing import Any, Generic, Protocol, Self, TypeVar
|
|
12
|
+
from uuid import UUID
|
|
13
|
+
|
|
14
|
+
from fastapi import Depends, Request
|
|
15
|
+
from fastapi_users import BaseUserManager, FastAPIUsers
|
|
16
|
+
from fastapi_users.authentication import AuthenticationBackend, BearerTransport, JWTStrategy
|
|
17
|
+
from fastapi_users.db import BaseUserDatabase
|
|
18
|
+
from fastapi_users.manager import UUIDIDMixin
|
|
19
|
+
from fastapi_users.models import UserProtocol
|
|
20
|
+
from tortoise import fields
|
|
21
|
+
|
|
22
|
+
from drfast.models import Model, TimestampedModel
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class TortoiseUserProtocol(UserProtocol[UUID], Protocol):
|
|
26
|
+
"""Structural contract required by the Tortoise FastAPI Users adapter."""
|
|
27
|
+
|
|
28
|
+
@classmethod
|
|
29
|
+
async def get_or_none(cls, **kwargs: Any) -> Self | None: ...
|
|
30
|
+
|
|
31
|
+
@classmethod
|
|
32
|
+
async def create(cls, **kwargs: Any) -> Self: ...
|
|
33
|
+
|
|
34
|
+
async def save(self, *, update_fields: list[str]) -> None: ...
|
|
35
|
+
|
|
36
|
+
async def delete(self) -> None: ...
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
UserType = TypeVar("UserType", bound=TortoiseUserProtocol)
|
|
40
|
+
AuthUserType = TypeVar("AuthUserType", bound=UserProtocol[UUID])
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class FastAPIUserModel(Model, TimestampedModel):
|
|
44
|
+
"""Ready-to-use Tortoise user table base with FastAPI Users fields."""
|
|
45
|
+
|
|
46
|
+
email = fields.CharField(max_length=320, unique=True, index=True)
|
|
47
|
+
hashed_password = fields.CharField(max_length=1024)
|
|
48
|
+
is_active = fields.BooleanField(default=True)
|
|
49
|
+
is_superuser = fields.BooleanField(default=False)
|
|
50
|
+
is_verified = fields.BooleanField(default=False)
|
|
51
|
+
|
|
52
|
+
class Meta:
|
|
53
|
+
abstract = True
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class TortoiseUserDatabase(BaseUserDatabase[UserType, UUID], Generic[UserType]):
|
|
57
|
+
"""FastAPI Users persistence adapter for a Tortoise UUID user model."""
|
|
58
|
+
|
|
59
|
+
def __init__(self, user_model: type[UserType]) -> None:
|
|
60
|
+
self.user_model = user_model
|
|
61
|
+
|
|
62
|
+
async def get(self, id: UUID) -> UserType | None:
|
|
63
|
+
return await self.user_model.get_or_none(id=id)
|
|
64
|
+
|
|
65
|
+
async def get_by_email(self, email: str) -> UserType | None:
|
|
66
|
+
return await self.user_model.get_or_none(email=email)
|
|
67
|
+
|
|
68
|
+
async def create(self, create_dict: dict[str, Any]) -> UserType:
|
|
69
|
+
return await self.user_model.create(**create_dict)
|
|
70
|
+
|
|
71
|
+
async def update(self, user: UserType, update_dict: dict[str, Any]) -> UserType:
|
|
72
|
+
for key, value in update_dict.items():
|
|
73
|
+
setattr(user, key, value)
|
|
74
|
+
await user.save(update_fields=list(update_dict))
|
|
75
|
+
return user
|
|
76
|
+
|
|
77
|
+
async def delete(self, user: UserType) -> None:
|
|
78
|
+
await user.delete()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class DRFastUserManager(UUIDIDMixin, BaseUserManager[UserType, UUID], Generic[UserType]):
|
|
82
|
+
"""Base manager applications subclass to add password and event policies."""
|
|
83
|
+
|
|
84
|
+
reset_password_token_secret: str
|
|
85
|
+
verification_token_secret: str
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def jwt_authentication(
|
|
89
|
+
secret: str,
|
|
90
|
+
lifetime_seconds: int = 3600,
|
|
91
|
+
) -> AuthenticationBackend[AuthUserType, UUID]:
|
|
92
|
+
"""Build FastAPI Users' bearer/JWT backend for generated projects."""
|
|
93
|
+
bearer_transport = BearerTransport(tokenUrl="auth/jwt/login")
|
|
94
|
+
|
|
95
|
+
def get_strategy() -> JWTStrategy[AuthUserType, UUID]:
|
|
96
|
+
return JWTStrategy(secret=secret, lifetime_seconds=lifetime_seconds)
|
|
97
|
+
|
|
98
|
+
return AuthenticationBackend[AuthUserType, UUID](
|
|
99
|
+
name="jwt", transport=bearer_transport, get_strategy=get_strategy
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def build_fastapi_users(
|
|
104
|
+
get_user_manager: Callable[..., AsyncGenerator[DRFastUserManager[UserType], None]],
|
|
105
|
+
backend: AuthenticationBackend[UserType, UUID],
|
|
106
|
+
) -> FastAPIUsers[UserType, UUID]:
|
|
107
|
+
"""Create a FastAPI Users facade from application dependencies."""
|
|
108
|
+
return FastAPIUsers(get_user_manager, [backend])
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def current_user_middleware(
|
|
112
|
+
current_active_user: Callable[..., Any],
|
|
113
|
+
) -> Callable[[Request, Any], Any]:
|
|
114
|
+
"""Adapt a FastAPI Users dependency for DRFast permission classes.
|
|
115
|
+
|
|
116
|
+
Use this with a normal FastAPI dependency on protected routes when request
|
|
117
|
+
state is desired by custom `BasePermission` implementations.
|
|
118
|
+
"""
|
|
119
|
+
async def dependency( # noqa: B008 - FastAPI resolves dependencies from the signature.
|
|
120
|
+
request: Request, user: Any = Depends(current_active_user) # noqa: B008
|
|
121
|
+
) -> Any:
|
|
122
|
+
request.state.user = user
|
|
123
|
+
return user
|
|
124
|
+
|
|
125
|
+
return dependency
|
|
126
|
+
|
drfast/cli.py
ADDED
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
"""Django-inspired management CLI for DRFast projects."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import asyncio
|
|
7
|
+
import importlib
|
|
8
|
+
import runpy
|
|
9
|
+
import sys
|
|
10
|
+
from getpass import getpass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from textwrap import dedent
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _write(path: Path, content: str) -> None:
|
|
16
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
17
|
+
path.write_text(dedent(content).lstrip(), encoding="utf-8")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def startproject(name: str, destination: str) -> None:
|
|
21
|
+
"""Generate a runnable application with local SQLite configuration."""
|
|
22
|
+
if not name.isidentifier():
|
|
23
|
+
raise SystemExit("Project name must be a valid Python identifier.")
|
|
24
|
+
root = Path(destination).resolve() / name
|
|
25
|
+
if root.exists() and any(root.iterdir()):
|
|
26
|
+
raise SystemExit(f"Refusing to overwrite non-empty directory: {root}")
|
|
27
|
+
package = name
|
|
28
|
+
_write(
|
|
29
|
+
root / "pyproject.toml",
|
|
30
|
+
f'''
|
|
31
|
+
[project]
|
|
32
|
+
name = "{name}"
|
|
33
|
+
version = "0.1.0"
|
|
34
|
+
requires-python = ">=3.11"
|
|
35
|
+
dependencies = ["drfast-api[postgres]"]
|
|
36
|
+
|
|
37
|
+
[tool.tortoise]
|
|
38
|
+
tortoise_orm = "{package}.settings.TORTOISE_ORM"
|
|
39
|
+
''',
|
|
40
|
+
)
|
|
41
|
+
_write(root / ".env.example", """DEBUG=true
|
|
42
|
+
DATABASE_URL=sqlite://db.sqlite3
|
|
43
|
+
SECRET_KEY=replace-before-production
|
|
44
|
+
""")
|
|
45
|
+
_write(root / package / "__init__.py", "")
|
|
46
|
+
_write(
|
|
47
|
+
root / package / "settings.py",
|
|
48
|
+
f'''
|
|
49
|
+
from drfast import DRFastSettings
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class Settings(DRFastSettings):
|
|
53
|
+
app_name: str = "{name}"
|
|
54
|
+
debug: bool = True
|
|
55
|
+
installed_apps: list[str] = [
|
|
56
|
+
"{package}.users.models",
|
|
57
|
+
"{package}.apps.todo.models",
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
settings = Settings()
|
|
62
|
+
TORTOISE_ORM = settings.tortoise_config()
|
|
63
|
+
''',
|
|
64
|
+
)
|
|
65
|
+
_write(root / package / "users" / "__init__.py", "")
|
|
66
|
+
_write(
|
|
67
|
+
root / package / "users" / "models.py",
|
|
68
|
+
"""
|
|
69
|
+
from drfast.auth import FastAPIUserModel
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class User(FastAPIUserModel):
|
|
73
|
+
pass
|
|
74
|
+
""",
|
|
75
|
+
)
|
|
76
|
+
_write(
|
|
77
|
+
root / package / "users" / "auth.py",
|
|
78
|
+
f'''
|
|
79
|
+
from collections.abc import AsyncGenerator
|
|
80
|
+
from uuid import UUID
|
|
81
|
+
|
|
82
|
+
from fastapi import Depends
|
|
83
|
+
from fastapi_users import schemas
|
|
84
|
+
from drfast.auth import (
|
|
85
|
+
DRFastUserManager,
|
|
86
|
+
TortoiseUserDatabase,
|
|
87
|
+
build_fastapi_users,
|
|
88
|
+
jwt_authentication,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
from {package}.settings import settings
|
|
92
|
+
from {package}.users.models import User
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class UserRead(schemas.BaseUser[UUID]):
|
|
96
|
+
pass
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class UserCreate(schemas.BaseUserCreate):
|
|
100
|
+
pass
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class UserUpdate(schemas.BaseUserUpdate):
|
|
104
|
+
pass
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
async def get_user_db() -> AsyncGenerator[TortoiseUserDatabase[User], None]:
|
|
108
|
+
yield TortoiseUserDatabase(User)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class UserManager(DRFastUserManager[User]):
|
|
112
|
+
reset_password_token_secret = settings.secret_key.get_secret_value()
|
|
113
|
+
verification_token_secret = settings.secret_key.get_secret_value()
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
async def get_user_manager(
|
|
117
|
+
user_db: TortoiseUserDatabase[User] = Depends(get_user_db),
|
|
118
|
+
) -> AsyncGenerator[UserManager, None]:
|
|
119
|
+
yield UserManager(user_db)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
auth_backend = jwt_authentication(
|
|
123
|
+
settings.secret_key.get_secret_value(), settings.jwt_lifetime_seconds
|
|
124
|
+
)
|
|
125
|
+
fastapi_users = build_fastapi_users(get_user_manager, auth_backend)
|
|
126
|
+
''',
|
|
127
|
+
)
|
|
128
|
+
_write(root / package / "apps" / "__init__.py", "")
|
|
129
|
+
_write(root / package / "apps" / "todo" / "__init__.py", "")
|
|
130
|
+
_write(
|
|
131
|
+
root / package / "apps" / "todo" / "models.py",
|
|
132
|
+
"""
|
|
133
|
+
from tortoise import fields
|
|
134
|
+
from drfast import Model, TimestampedModel
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class Todo(Model, TimestampedModel):
|
|
138
|
+
title = fields.CharField(max_length=200)
|
|
139
|
+
completed = fields.BooleanField(default=False)
|
|
140
|
+
""",
|
|
141
|
+
)
|
|
142
|
+
_write(
|
|
143
|
+
root / package / "apps" / "todo" / "serializers.py",
|
|
144
|
+
f'''
|
|
145
|
+
from drfast import BooleanField, CharField, ModelSerializer, UUIDField
|
|
146
|
+
from {package}.apps.todo.models import Todo
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class TodoSerializer(ModelSerializer[Todo]):
|
|
150
|
+
model = Todo
|
|
151
|
+
id = UUIDField(read_only=True)
|
|
152
|
+
title = CharField()
|
|
153
|
+
completed = BooleanField(required=False, default=False)
|
|
154
|
+
''',
|
|
155
|
+
)
|
|
156
|
+
_write(
|
|
157
|
+
root / package / "apps" / "todo" / "views.py",
|
|
158
|
+
f'''
|
|
159
|
+
from drfast import FilterSet, ModelViewSet
|
|
160
|
+
from {package}.apps.todo.models import Todo
|
|
161
|
+
from {package}.apps.todo.serializers import TodoSerializer
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class TodoFilters(FilterSet):
|
|
165
|
+
fields = {{"completed": ("exact",)}}
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class TodoViewSet(ModelViewSet):
|
|
169
|
+
model = Todo
|
|
170
|
+
serializer_class = TodoSerializer
|
|
171
|
+
filterset_class = TodoFilters
|
|
172
|
+
ordering_fields = ("created_at", "title")
|
|
173
|
+
''',
|
|
174
|
+
)
|
|
175
|
+
_write(
|
|
176
|
+
root / package / "urls.py",
|
|
177
|
+
f'''
|
|
178
|
+
from drfast import DefaultRouter
|
|
179
|
+
from {package}.apps.todo.views import TodoViewSet
|
|
180
|
+
|
|
181
|
+
router = DefaultRouter()
|
|
182
|
+
router.register("/todos", TodoViewSet, basename="todos")
|
|
183
|
+
''',
|
|
184
|
+
)
|
|
185
|
+
_write(
|
|
186
|
+
root / package / "main.py",
|
|
187
|
+
f'''
|
|
188
|
+
from drfast.orm import create_app
|
|
189
|
+
from {package}.settings import settings
|
|
190
|
+
from {package}.urls import router
|
|
191
|
+
from {package}.users.auth import (
|
|
192
|
+
UserCreate,
|
|
193
|
+
UserRead,
|
|
194
|
+
UserUpdate,
|
|
195
|
+
auth_backend,
|
|
196
|
+
fastapi_users,
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
app = create_app(settings)
|
|
200
|
+
router.include_in(app, prefix="/api")
|
|
201
|
+
app.include_router(
|
|
202
|
+
fastapi_users.get_auth_router(auth_backend), prefix="/auth/jwt", tags=["auth"]
|
|
203
|
+
)
|
|
204
|
+
app.include_router(
|
|
205
|
+
fastapi_users.get_register_router(UserRead, UserCreate), prefix="/auth", tags=["auth"]
|
|
206
|
+
)
|
|
207
|
+
app.include_router(
|
|
208
|
+
fastapi_users.get_users_router(UserRead, UserUpdate), prefix="/users", tags=["users"]
|
|
209
|
+
)
|
|
210
|
+
''',
|
|
211
|
+
)
|
|
212
|
+
_write(
|
|
213
|
+
root / "README.md",
|
|
214
|
+
f"# {name}\n\nRun `drfast init`, `drfast migrate`, then `drfast runserver`.\n",
|
|
215
|
+
)
|
|
216
|
+
print(f"Created {root}")
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def startapp(name: str, project: str = ".") -> None:
|
|
220
|
+
"""Generate a conventional models/serializers/views app package."""
|
|
221
|
+
if not name.isidentifier():
|
|
222
|
+
raise SystemExit("App name must be a valid Python identifier.")
|
|
223
|
+
project_root = Path(project).resolve()
|
|
224
|
+
root = project_root / "apps" / name
|
|
225
|
+
if root.exists():
|
|
226
|
+
raise SystemExit(f"App already exists: {root}")
|
|
227
|
+
for filename in ("__init__.py", "models.py", "serializers.py", "views.py", "tests.py"):
|
|
228
|
+
_write(root / filename, "")
|
|
229
|
+
package = project_root.name.replace("-", "_")
|
|
230
|
+
settings_path = project_root / package / "settings.py"
|
|
231
|
+
if settings_path.exists():
|
|
232
|
+
settings = settings_path.read_text(encoding="utf-8")
|
|
233
|
+
marker = "\t]\n\n\nsettings = Settings()"
|
|
234
|
+
entry = f'\t\t"{package}.apps.{name}.models",\n'
|
|
235
|
+
if marker not in settings:
|
|
236
|
+
raise SystemExit(f"Could not update installed_apps in {settings_path}")
|
|
237
|
+
settings_path.write_text(settings.replace(marker, f"{entry}{marker}", 1), encoding="utf-8")
|
|
238
|
+
print(f"Created {root}")
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _run_tortoise(arguments: list[str]) -> int:
|
|
242
|
+
"""Run Tortoise's supported module CLI without relying on ``PATH``."""
|
|
243
|
+
original_argv = sys.argv
|
|
244
|
+
try:
|
|
245
|
+
sys.argv = ["tortoise", *arguments]
|
|
246
|
+
runpy.run_module("tortoise", run_name="__main__")
|
|
247
|
+
except SystemExit as exc:
|
|
248
|
+
return exc.code if isinstance(exc.code, int) else 1
|
|
249
|
+
finally:
|
|
250
|
+
sys.argv = original_argv
|
|
251
|
+
return 0
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
async def _create_superuser(project: str, email: str) -> None:
|
|
255
|
+
settings_module = importlib.import_module(f"{project}.settings")
|
|
256
|
+
user_module = importlib.import_module(f"{project}.users.models")
|
|
257
|
+
from fastapi_users.password import PasswordHelper
|
|
258
|
+
from tortoise import Tortoise
|
|
259
|
+
|
|
260
|
+
password = getpass("Password: ")
|
|
261
|
+
confirm = getpass("Password (again): ")
|
|
262
|
+
if password != confirm:
|
|
263
|
+
raise SystemExit("Passwords do not match.")
|
|
264
|
+
await Tortoise.init(config=settings_module.TORTOISE_ORM)
|
|
265
|
+
try:
|
|
266
|
+
await user_module.User.create(
|
|
267
|
+
email=email,
|
|
268
|
+
hashed_password=PasswordHelper().hash(password),
|
|
269
|
+
is_active=True,
|
|
270
|
+
is_superuser=True,
|
|
271
|
+
is_verified=True,
|
|
272
|
+
)
|
|
273
|
+
finally:
|
|
274
|
+
await Tortoise.close_connections()
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _project_name() -> str:
|
|
278
|
+
return Path.cwd().name.replace("-", "_")
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _validate_project_name(project: str) -> str:
|
|
282
|
+
if not project or not all(part.isidentifier() for part in project.split(".")):
|
|
283
|
+
raise SystemExit("Project must be a dotted Python module name.")
|
|
284
|
+
return project
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def main(argv: list[str] | None = None) -> None:
|
|
288
|
+
parser = argparse.ArgumentParser(prog="drfast")
|
|
289
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
290
|
+
project_parser = subparsers.add_parser("startproject")
|
|
291
|
+
project_parser.add_argument("name")
|
|
292
|
+
project_parser.add_argument("destination", nargs="?", default=".")
|
|
293
|
+
app_parser = subparsers.add_parser("startapp")
|
|
294
|
+
app_parser.add_argument("name")
|
|
295
|
+
app_parser.add_argument("--project", default=".")
|
|
296
|
+
run_parser = subparsers.add_parser("runserver")
|
|
297
|
+
run_parser.add_argument("--host", default="127.0.0.1")
|
|
298
|
+
run_parser.add_argument("--port", type=int, default=8000)
|
|
299
|
+
run_parser.add_argument("--project", default=None)
|
|
300
|
+
subparsers.add_parser("check")
|
|
301
|
+
subparsers.add_parser("routes")
|
|
302
|
+
user_parser = subparsers.add_parser("createsuperuser")
|
|
303
|
+
user_parser.add_argument("--email", required=True)
|
|
304
|
+
user_parser.add_argument("--project", default=None)
|
|
305
|
+
for command in (
|
|
306
|
+
"init",
|
|
307
|
+
"makemigrations",
|
|
308
|
+
"migrate",
|
|
309
|
+
"downgrade",
|
|
310
|
+
"history",
|
|
311
|
+
"heads",
|
|
312
|
+
"sqlmigrate",
|
|
313
|
+
):
|
|
314
|
+
migration_parser = subparsers.add_parser(command)
|
|
315
|
+
migration_parser.add_argument("args", nargs=argparse.REMAINDER)
|
|
316
|
+
|
|
317
|
+
args = parser.parse_args(argv)
|
|
318
|
+
if args.command == "startproject":
|
|
319
|
+
startproject(args.name, args.destination)
|
|
320
|
+
return
|
|
321
|
+
if args.command == "startapp":
|
|
322
|
+
startapp(args.name, args.project)
|
|
323
|
+
return
|
|
324
|
+
if args.command == "runserver":
|
|
325
|
+
project = _validate_project_name(args.project or _project_name())
|
|
326
|
+
import uvicorn
|
|
327
|
+
|
|
328
|
+
uvicorn.run(f"{project}.main:app", host=args.host, port=args.port, reload=True)
|
|
329
|
+
return
|
|
330
|
+
if args.command in {
|
|
331
|
+
"init",
|
|
332
|
+
"makemigrations",
|
|
333
|
+
"migrate",
|
|
334
|
+
"downgrade",
|
|
335
|
+
"history",
|
|
336
|
+
"heads",
|
|
337
|
+
"sqlmigrate",
|
|
338
|
+
}:
|
|
339
|
+
raise SystemExit(_run_tortoise([args.command, *args.args]))
|
|
340
|
+
if args.command == "createsuperuser":
|
|
341
|
+
project = _validate_project_name(args.project or _project_name())
|
|
342
|
+
asyncio.run(_create_superuser(project, args.email))
|
|
343
|
+
return
|
|
344
|
+
project = _validate_project_name(_project_name())
|
|
345
|
+
if args.command == "check":
|
|
346
|
+
importlib.import_module(f"{project}.main")
|
|
347
|
+
print("System check identified no issues.")
|
|
348
|
+
return
|
|
349
|
+
if args.command == "routes":
|
|
350
|
+
app = importlib.import_module(f"{project}.main").app
|
|
351
|
+
for route in app.routes:
|
|
352
|
+
methods = ",".join(sorted(getattr(route, "methods", [])))
|
|
353
|
+
print(f"{getattr(route, 'path', ''):30} {methods}")
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
if __name__ == "__main__":
|
|
357
|
+
main()
|
|
358
|
+
|
drfast/filters.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Explicit filtering configuration for list endpoints."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import ClassVar
|
|
6
|
+
|
|
7
|
+
from fastapi import HTTPException, Request, status
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class FilterSet:
|
|
11
|
+
"""Allowlist exact filters and explicitly declared Tortoise lookups."""
|
|
12
|
+
|
|
13
|
+
fields: ClassVar[dict[str, tuple[str, ...]] | tuple[str, ...]] = ()
|
|
14
|
+
|
|
15
|
+
@classmethod
|
|
16
|
+
def allowed_lookups(cls, name: str) -> tuple[str, ...]:
|
|
17
|
+
if isinstance(cls.fields, dict):
|
|
18
|
+
return cls.fields.get(name, ())
|
|
19
|
+
return ("exact",) if name in cls.fields else ()
|
|
20
|
+
|
|
21
|
+
@classmethod
|
|
22
|
+
def filters_from_request(cls, request: Request) -> dict[str, str]:
|
|
23
|
+
filters: dict[str, str] = {}
|
|
24
|
+
for name, value in request.query_params.multi_items():
|
|
25
|
+
if name in {"limit", "offset", "ordering"}:
|
|
26
|
+
continue
|
|
27
|
+
field, separator, lookup = name.partition("__")
|
|
28
|
+
lookup = lookup if separator else "exact"
|
|
29
|
+
if lookup not in cls.allowed_lookups(field):
|
|
30
|
+
raise HTTPException(
|
|
31
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
32
|
+
detail=f"Filtering by '{name}' is not allowed.",
|
|
33
|
+
)
|
|
34
|
+
filters[name] = value
|
|
35
|
+
return filters
|
|
36
|
+
|
drfast/middleware.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Class-based application middleware with deterministic hook ordering."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from fastapi import FastAPI, Request, Response
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class BaseMiddleware:
|
|
11
|
+
"""Implement any async hook; returning a response from request short-circuits."""
|
|
12
|
+
|
|
13
|
+
async def process_request(self, request: Request) -> Response | None:
|
|
14
|
+
return None
|
|
15
|
+
|
|
16
|
+
async def process_response(self, request: Request, response: Response) -> Response:
|
|
17
|
+
return response
|
|
18
|
+
|
|
19
|
+
async def process_exception(self, request: Request, exc: Exception) -> Response | None:
|
|
20
|
+
return None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def install_middleware(app: FastAPI, middleware_classes: list[type[BaseMiddleware]]) -> None:
|
|
24
|
+
"""Install one ASGI-compatible pipeline around FastAPI routes.
|
|
25
|
+
|
|
26
|
+
Request hooks run in declaration order; response and exception hooks run in
|
|
27
|
+
reverse order, matching stack unwinding semantics.
|
|
28
|
+
"""
|
|
29
|
+
instances = [middleware() for middleware in middleware_classes]
|
|
30
|
+
|
|
31
|
+
@app.middleware("http")
|
|
32
|
+
async def drfast_middleware(request: Request, call_next: Any) -> Response:
|
|
33
|
+
try:
|
|
34
|
+
for middleware in instances:
|
|
35
|
+
response = await middleware.process_request(request)
|
|
36
|
+
if response is not None:
|
|
37
|
+
return response
|
|
38
|
+
response = await call_next(request)
|
|
39
|
+
except Exception as exc:
|
|
40
|
+
for middleware in reversed(instances):
|
|
41
|
+
response = await middleware.process_exception(request, exc)
|
|
42
|
+
if response is not None:
|
|
43
|
+
return response
|
|
44
|
+
raise
|
|
45
|
+
for middleware in reversed(instances):
|
|
46
|
+
response = await middleware.process_response(request, response)
|
|
47
|
+
return response
|
|
48
|
+
|
drfast/models.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Tortoise model bases with explicit, composable conventions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from tortoise import fields
|
|
6
|
+
from tortoise.models import Model as TortoiseModel
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Model(TortoiseModel):
|
|
10
|
+
"""Abstract model with a UUID primary key."""
|
|
11
|
+
|
|
12
|
+
id = fields.UUIDField(primary_key=True)
|
|
13
|
+
|
|
14
|
+
class Meta:
|
|
15
|
+
abstract = True
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class TimestampedModel(TortoiseModel):
|
|
19
|
+
"""Opt-in UTC creation and modification timestamps."""
|
|
20
|
+
|
|
21
|
+
created_at = fields.DatetimeField(auto_now_add=True)
|
|
22
|
+
updated_at = fields.DatetimeField(auto_now=True)
|
|
23
|
+
|
|
24
|
+
class Meta:
|
|
25
|
+
abstract = True
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class SoftDeleteModel(TortoiseModel):
|
|
29
|
+
"""Opt-in soft deletion convention.
|
|
30
|
+
|
|
31
|
+
Applications choose whether ordinary queries exclude deleted rows; no manager
|
|
32
|
+
behavior is hidden by this mixin.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
deleted_at = fields.DatetimeField(null=True)
|
|
36
|
+
|
|
37
|
+
class Meta:
|
|
38
|
+
abstract = True
|
|
39
|
+
|
|
40
|
+
async def soft_delete(self) -> None:
|
|
41
|
+
from datetime import UTC, datetime
|
|
42
|
+
|
|
43
|
+
self.deleted_at = datetime.now(UTC)
|
|
44
|
+
await self.save(update_fields=["deleted_at"])
|
|
45
|
+
|
drfast/orm.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""FastAPI lifespan wiring for Tortoise ORM."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import AsyncIterator
|
|
6
|
+
from contextlib import asynccontextmanager
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from fastapi import FastAPI
|
|
10
|
+
from tortoise.contrib.fastapi import RegisterTortoise
|
|
11
|
+
|
|
12
|
+
from drfast.settings import DRFastSettings
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def configure_orm(app: FastAPI, settings: DRFastSettings) -> None:
|
|
16
|
+
"""Attach a Tortoise lifespan to an application.
|
|
17
|
+
|
|
18
|
+
Schema generation is deliberately never enabled here; use migrations instead.
|
|
19
|
+
"""
|
|
20
|
+
settings.validate_production()
|
|
21
|
+
|
|
22
|
+
@asynccontextmanager
|
|
23
|
+
async def lifespan(_: FastAPI) -> AsyncIterator[None]:
|
|
24
|
+
async with RegisterTortoise(config=settings.tortoise_config()):
|
|
25
|
+
yield
|
|
26
|
+
|
|
27
|
+
app.router.lifespan_context = lifespan
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def create_app(settings: DRFastSettings, **kwargs: Any) -> FastAPI:
|
|
31
|
+
"""Create a configured FastAPI application without hiding FastAPI itself."""
|
|
32
|
+
app = FastAPI(title=settings.app_name, **kwargs)
|
|
33
|
+
configure_orm(app, settings)
|
|
34
|
+
return app
|
|
35
|
+
|