forge-kits 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.
- forge_kits-0.1.0.dist-info/METADATA +1233 -0
- forge_kits-0.1.0.dist-info/RECORD +60 -0
- forge_kits-0.1.0.dist-info/WHEEL +4 -0
- forge_kits-0.1.0.dist-info/entry_points.txt +2 -0
- forgeapi/__init__.py +61 -0
- forgeapi/auth/__init__.py +42 -0
- forgeapi/auth/backend.py +186 -0
- forgeapi/auth/models.py +18 -0
- forgeapi/auth/strategies/__init__.py +6 -0
- forgeapi/auth/strategies/base.py +21 -0
- forgeapi/auth/strategies/cookie.py +169 -0
- forgeapi/auth/strategies/jwt.py +157 -0
- forgeapi/auth/strategies/telegram.py +139 -0
- forgeapi/cli/__init__.py +3 -0
- forgeapi/cli/commands/__init__.py +0 -0
- forgeapi/cli/commands/boilerplate_cmd.py +689 -0
- forgeapi/cli/commands/db_cmd.py +28 -0
- forgeapi/cli/commands/fresh_cmd.py +81 -0
- forgeapi/cli/commands/generate_cmd.py +277 -0
- forgeapi/cli/commands/generate_schema_cmd.py +321 -0
- forgeapi/cli/commands/init_cmd.py +312 -0
- forgeapi/cli/commands/models_cmd.py +70 -0
- forgeapi/cli/commands/routers_cmd.py +77 -0
- forgeapi/cli/commands/runserver_cmd.py +43 -0
- forgeapi/cli/commands/seed_cmd.py +115 -0
- forgeapi/cli/main.py +402 -0
- forgeapi/cli/templates/controller.py.jinja2 +16 -0
- forgeapi/cli/templates/event.py.jinja2 +8 -0
- forgeapi/cli/templates/listener.py.jinja2 +7 -0
- forgeapi/cli/templates/model.py.jinja2 +11 -0
- forgeapi/cli/templates/schema.py.jinja2 +13 -0
- forgeapi/cli/templates/seeder.py.jinja2 +6 -0
- forgeapi/config.py +63 -0
- forgeapi/controllers/__init__.py +3 -0
- forgeapi/controllers/base.py +99 -0
- forgeapi/database/__init__.py +3 -0
- forgeapi/database/seeder.py +26 -0
- forgeapi/events/__init__.py +5 -0
- forgeapi/events/bus.py +185 -0
- forgeapi/events/decorators.py +53 -0
- forgeapi/events/event.py +61 -0
- forgeapi/kit.py +256 -0
- forgeapi/middleware/__init__.py +15 -0
- forgeapi/middleware/base_middleware.py +42 -0
- forgeapi/middleware/cors.py +18 -0
- forgeapi/middleware/guard.py +61 -0
- forgeapi/middleware/logging.py +26 -0
- forgeapi/middleware/rate_limit.py +37 -0
- forgeapi/middleware/request_id.py +14 -0
- forgeapi/pagination/__init__.py +3 -0
- forgeapi/pagination/paginator.py +77 -0
- forgeapi/permissions/__init__.py +14 -0
- forgeapi/permissions/dependencies.py +66 -0
- forgeapi/permissions/mixins.py +178 -0
- forgeapi/permissions/models.py +96 -0
- forgeapi/permissions/registry.py +30 -0
- forgeapi/schemas/__init__.py +3 -0
- forgeapi/schemas/base.py +41 -0
- forgeapi/settings/__init__.py +3 -0
- forgeapi/settings/base.py +28 -0
|
@@ -0,0 +1,1233 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: forge-kits
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: FastAPI toolkit — auth, pagination, CLI generator
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Requires-Dist: fastapi>=0.138
|
|
7
|
+
Requires-Dist: jinja2>=3.1.6
|
|
8
|
+
Requires-Dist: pydantic-settings>=2.14
|
|
9
|
+
Requires-Dist: pydantic>=2.13
|
|
10
|
+
Requires-Dist: rich>=15.0
|
|
11
|
+
Requires-Dist: typer>=0.26
|
|
12
|
+
Provides-Extra: aiomysql
|
|
13
|
+
Requires-Dist: aiomysql>=0.2; extra == 'aiomysql'
|
|
14
|
+
Requires-Dist: tortoise-orm>=1.1.7; extra == 'aiomysql'
|
|
15
|
+
Provides-Extra: aiosqlite
|
|
16
|
+
Requires-Dist: aiosqlite>=0.21; extra == 'aiosqlite'
|
|
17
|
+
Requires-Dist: tortoise-orm>=1.1.7; extra == 'aiosqlite'
|
|
18
|
+
Provides-Extra: asyncpg
|
|
19
|
+
Requires-Dist: asyncpg>=0.30; extra == 'asyncpg'
|
|
20
|
+
Requires-Dist: tortoise-orm>=1.1.7; extra == 'asyncpg'
|
|
21
|
+
Provides-Extra: auth
|
|
22
|
+
Requires-Dist: pyjwt>=2.13; extra == 'auth'
|
|
23
|
+
Provides-Extra: db
|
|
24
|
+
Requires-Dist: tortoise-orm>=1.1.7; extra == 'db'
|
|
25
|
+
Provides-Extra: full
|
|
26
|
+
Requires-Dist: forgeapi[aiomysql,aiosqlite,asyncpg,auth]; extra == 'full'
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
|
|
29
|
+
# ForgeAPI — Documentation
|
|
30
|
+
|
|
31
|
+
## Table of Contents
|
|
32
|
+
|
|
33
|
+
1. [Quick start](#1-quick-start)
|
|
34
|
+
2. [Project structure](#2-project-structure)
|
|
35
|
+
3. [Core](#3-core)
|
|
36
|
+
4. [Auth](#4-auth)
|
|
37
|
+
- [How it works](#how-it-works)
|
|
38
|
+
- [CurrentUser and OptionalUser](#currentuser-and-optionaluser)
|
|
39
|
+
- [JWT strategy](#jwt-strategy)
|
|
40
|
+
- [Cookie strategy](#cookie-strategy)
|
|
41
|
+
- [Telegram strategy](#telegram-strategy)
|
|
42
|
+
5. [Pagination](#5-pagination)
|
|
43
|
+
6. [Events](#6-events)
|
|
44
|
+
- [Defining events](#defining-events)
|
|
45
|
+
- [@listen decorator](#listen-decorator)
|
|
46
|
+
- [Dispatching](#dispatching)
|
|
47
|
+
- [EventBus](#eventbus)
|
|
48
|
+
7. [Controllers](#7-controllers)
|
|
49
|
+
- [Base pattern](#base-pattern)
|
|
50
|
+
- [Route decorator](#route-decorator)
|
|
51
|
+
- [Auto-prefix and namespace](#auto-prefix-and-namespace)
|
|
52
|
+
8. [Schemas](#8-schemas)
|
|
53
|
+
- [Base classes](#base-classes)
|
|
54
|
+
- [Schema directories](#schema-directories)
|
|
55
|
+
- [generate:schema](#generateschema)
|
|
56
|
+
9. [Permissions](#9-permissions)
|
|
57
|
+
- [Setup](#setup)
|
|
58
|
+
- [PermissionsMixin](#permissionsmixin)
|
|
59
|
+
- [Dependencies](#dependencies)
|
|
60
|
+
- [Role and Permission models](#role-and-permission-models)
|
|
61
|
+
10. [Middleware](#10-middleware)
|
|
62
|
+
- [CORS](#cors)
|
|
63
|
+
- [Rate limiting](#rate-limiting)
|
|
64
|
+
- [Request ID](#request-id)
|
|
65
|
+
- [Access logging](#access-logging)
|
|
66
|
+
11. [Settings](#11-settings)
|
|
67
|
+
12. [CLI reference](#12-cli-reference)
|
|
68
|
+
13. [forgeapi.toml reference](#13-forgeapitoml-reference)
|
|
69
|
+
|
|
70
|
+
---
|
|
71
|
+
|
|
72
|
+
## 1. Quick start
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
pip install forgeapi
|
|
76
|
+
forgeapi init my-project
|
|
77
|
+
cd my-project
|
|
78
|
+
|
|
79
|
+
forgeapi db:init && forgeapi db:makemigrations && forgeapi db:migrate
|
|
80
|
+
forgeapi runserver --reload
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
`forgeapi init` asks for auth strategy (jwt / cookie / telegram), DB driver (asyncpg / aiosqlite / aiomysql), and whether to generate the welcome boilerplate (User + Post + events).
|
|
84
|
+
|
|
85
|
+
---
|
|
86
|
+
|
|
87
|
+
## 2. Project structure
|
|
88
|
+
|
|
89
|
+
After `forgeapi init my-project`:
|
|
90
|
+
|
|
91
|
+
```
|
|
92
|
+
my-project/
|
|
93
|
+
main.py # entry point — FastAPI app + Core(...)
|
|
94
|
+
forgeapi.toml # project config
|
|
95
|
+
.env # secrets (JWT_SECRET, DB_* etc.)
|
|
96
|
+
pyproject.toml
|
|
97
|
+
app/
|
|
98
|
+
config.py # TORTOISE_ORM dict
|
|
99
|
+
models/ # Tortoise models
|
|
100
|
+
controllers/ # *_controller.py files, auto-loaded by Core
|
|
101
|
+
schemas/
|
|
102
|
+
payload/ # request / input schemas
|
|
103
|
+
response/ # response / output schemas
|
|
104
|
+
events/ # Event subclasses
|
|
105
|
+
listeners/ # @listen(...) handlers
|
|
106
|
+
migrations/
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
**`main.py`**:
|
|
110
|
+
|
|
111
|
+
```python
|
|
112
|
+
from fastapi import FastAPI
|
|
113
|
+
from forgeapi import Core
|
|
114
|
+
from tortoise.contrib.fastapi import register_tortoise
|
|
115
|
+
from app.config import TORTOISE_ORM
|
|
116
|
+
|
|
117
|
+
app = FastAPI()
|
|
118
|
+
|
|
119
|
+
core = Core(
|
|
120
|
+
app,
|
|
121
|
+
auth=True,
|
|
122
|
+
cors=["*"],
|
|
123
|
+
rate_limit=60,
|
|
124
|
+
pagination=20,
|
|
125
|
+
request_id=True,
|
|
126
|
+
events=True,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
register_tortoise(app, config=TORTOISE_ORM, generate_schemas=False, add_exception_handlers=True)
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
---
|
|
133
|
+
|
|
134
|
+
## 3. Core
|
|
135
|
+
|
|
136
|
+
`Core` wires up all modules in one place.
|
|
137
|
+
|
|
138
|
+
```python
|
|
139
|
+
from forgeapi import Core
|
|
140
|
+
|
|
141
|
+
core = Core(
|
|
142
|
+
app,
|
|
143
|
+
auth=True, # auth strategy
|
|
144
|
+
cors=["*"], # CORS origins
|
|
145
|
+
rate_limit=60, # requests per minute
|
|
146
|
+
pagination=20, # default page size
|
|
147
|
+
request_id=True, # X-Request-ID header
|
|
148
|
+
events=True, # auto-load listeners
|
|
149
|
+
permissions=User, # enable permissions (pass your User model)
|
|
150
|
+
logging=True, # access log (default True)
|
|
151
|
+
controllers=True, # auto-discover controllers (default True)
|
|
152
|
+
)
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
### Options
|
|
156
|
+
|
|
157
|
+
| Argument | Type | Default | Description |
|
|
158
|
+
|---|---|---|---|
|
|
159
|
+
| `auth` | `bool \| str` | `False` | `True` = strategy from toml; `"jwt"` / `"cookie"` / `"telegram"` = override |
|
|
160
|
+
| `cors` | `bool \| list[str]` | `False` | `True` = allow all; list = specific origins |
|
|
161
|
+
| `rate_limit` | `bool \| int` | `False` | `True` = 60 req/min; int = custom limit per IP |
|
|
162
|
+
| `pagination` | `bool \| int` | `False` | `True` = limits from toml; int = default_limit |
|
|
163
|
+
| `request_id` | `bool` | `False` | Injects `X-Request-ID` header into every response |
|
|
164
|
+
| `events` | `bool` | `False` | Auto-loads all `*.py` files from `listeners_dir` |
|
|
165
|
+
| `permissions` | `Type \| None` | `None` | Pass your User model class to enable `RequirePermission`/`RequireRole` |
|
|
166
|
+
| `logging` | `bool` | `True` | Logs method + path + status + duration for every request |
|
|
167
|
+
| `controllers` | `bool` | `True` | Auto-imports `*_controller.py` (recursive) and registers routers |
|
|
168
|
+
| `config_path` | `str` | `"forgeapi.toml"` | Path to the TOML config file |
|
|
169
|
+
|
|
170
|
+
### Accessing after setup
|
|
171
|
+
|
|
172
|
+
```python
|
|
173
|
+
core.auth # → AuthBackend | None
|
|
174
|
+
core.config # → KitConfig (parsed forgeapi.toml)
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
### Including routers manually
|
|
178
|
+
|
|
179
|
+
```python
|
|
180
|
+
core.include_router(admin_router) # prefix: /api/v1
|
|
181
|
+
core.include_router(admin_router, prefix="/admin") # prefix: /api/v1/admin
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
---
|
|
185
|
+
|
|
186
|
+
## 4. Auth
|
|
187
|
+
|
|
188
|
+
### How it works
|
|
189
|
+
|
|
190
|
+
Strategy pattern — three built-ins: JWT, Cookie, Telegram. Pick one in `forgeapi.toml`.
|
|
191
|
+
|
|
192
|
+
When `Core(app, auth=True)` runs:
|
|
193
|
+
1. Strategy is built from config / env vars.
|
|
194
|
+
2. `AuthBackend` is registered as a global singleton.
|
|
195
|
+
3. `CurrentUser` and `OptionalUser` become live FastAPI dependencies.
|
|
196
|
+
|
|
197
|
+
### CurrentUser and OptionalUser
|
|
198
|
+
|
|
199
|
+
```python
|
|
200
|
+
from forgeapi.auth import CurrentUser, OptionalUser
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
**`CurrentUser`** — required auth. Returns `AuthUser` or raises `401`.
|
|
204
|
+
|
|
205
|
+
```python
|
|
206
|
+
@route.get("/me")
|
|
207
|
+
async def me(self, user: CurrentUser):
|
|
208
|
+
return {"id": user.id, "username": user.username}
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
**`OptionalUser`** — returns `AuthUser` if credentials are present, `None` otherwise. Never raises 401.
|
|
212
|
+
|
|
213
|
+
```python
|
|
214
|
+
@route.get("/feed")
|
|
215
|
+
async def feed(self, user: OptionalUser):
|
|
216
|
+
return personalised_feed(user.id) if user else public_feed()
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
### AuthUser fields
|
|
220
|
+
|
|
221
|
+
| Field | Type | Description |
|
|
222
|
+
|---|---|---|
|
|
223
|
+
| `user.id` | `Any` | JWT/Cookie: value of `sub` claim (string). Telegram: `telegram_id` (int). |
|
|
224
|
+
| `user.username` | `str \| None` | Username from token / initData |
|
|
225
|
+
| `user.auth_method` | `str` | `"jwt"` / `"cookie"` / `"telegram"` |
|
|
226
|
+
| `user.extra` | `dict` | Extra claims not in standard fields |
|
|
227
|
+
|
|
228
|
+
> JWT `user.id` is always a **string**. Cast when needed: `int(user.id)`.
|
|
229
|
+
|
|
230
|
+
---
|
|
231
|
+
|
|
232
|
+
### JWT strategy
|
|
233
|
+
|
|
234
|
+
Reads from `Authorization: Bearer <token>`.
|
|
235
|
+
|
|
236
|
+
```toml
|
|
237
|
+
[auth]
|
|
238
|
+
strategy = "jwt"
|
|
239
|
+
jwt_secret_env = "JWT_SECRET" # env var name
|
|
240
|
+
access_ttl_minutes = 30
|
|
241
|
+
refresh_ttl_days = 7
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
```python
|
|
245
|
+
from forgeapi.auth.backend import _global_backend
|
|
246
|
+
|
|
247
|
+
strategy = _global_backend.strategy # JWTStrategy
|
|
248
|
+
|
|
249
|
+
# issue tokens
|
|
250
|
+
access = strategy.create_access_token({"sub": str(user.id), "username": user.username})
|
|
251
|
+
refresh = strategy.create_refresh_token({"sub": str(user.id)})
|
|
252
|
+
|
|
253
|
+
# decode manually
|
|
254
|
+
payload = strategy.decode(token) # raises 401 on invalid/expired
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
Extra claims land in `user.extra`:
|
|
258
|
+
|
|
259
|
+
```python
|
|
260
|
+
token = strategy.create_access_token({"sub": "42", "username": "alice", "role": "admin"})
|
|
261
|
+
# in a route:
|
|
262
|
+
user.extra["role"] # → "admin"
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
---
|
|
266
|
+
|
|
267
|
+
### Cookie strategy
|
|
268
|
+
|
|
269
|
+
Stores a signed JSON session in an `HttpOnly` cookie.
|
|
270
|
+
|
|
271
|
+
```toml
|
|
272
|
+
[auth]
|
|
273
|
+
strategy = "cookie"
|
|
274
|
+
cookie_name = "session"
|
|
275
|
+
cookie_httponly = true
|
|
276
|
+
cookie_secure = false # set true in production
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
```python
|
|
280
|
+
from forgeapi.auth.backend import _global_backend
|
|
281
|
+
from fastapi import Response
|
|
282
|
+
|
|
283
|
+
strategy = _global_backend.strategy # CookieStrategy
|
|
284
|
+
|
|
285
|
+
# login
|
|
286
|
+
strategy.set_cookie(response, {"sub": str(user.id), "username": user.username})
|
|
287
|
+
|
|
288
|
+
# logout
|
|
289
|
+
strategy.delete_cookie(response)
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
Cookie is signed with HMAC-SHA256. Invalid signature → `401`. Secret from `COOKIE_SECRET` env var.
|
|
293
|
+
|
|
294
|
+
---
|
|
295
|
+
|
|
296
|
+
### Telegram strategy
|
|
297
|
+
|
|
298
|
+
Validates `initData` from Telegram Mini App. No login endpoint needed — auth happens on every request.
|
|
299
|
+
|
|
300
|
+
```toml
|
|
301
|
+
[auth]
|
|
302
|
+
strategy = "telegram"
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
```bash
|
|
306
|
+
TELEGRAM_BOT_TOKEN=123456:ABC-your-token
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
Client sends `window.Telegram.WebApp.initData` in:
|
|
310
|
+
- `X-Telegram-Init-Data: <initData>` header (preferred)
|
|
311
|
+
- `Authorization: tma <initData>` header
|
|
312
|
+
|
|
313
|
+
```python
|
|
314
|
+
async def me(self, user: CurrentUser):
|
|
315
|
+
user.id # telegram_id (int)
|
|
316
|
+
user.username # @username or None
|
|
317
|
+
user.extra # {"first_name": ..., "last_name": ..., "language_code": ..., "auth_date": ...}
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
Manual validation (e.g. webhooks):
|
|
321
|
+
|
|
322
|
+
```python
|
|
323
|
+
from forgeapi.auth.backend import _global_backend
|
|
324
|
+
|
|
325
|
+
tg_user = _global_backend.strategy.validate_init_data(raw_init_data_string)
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
---
|
|
329
|
+
|
|
330
|
+
## 5. Pagination
|
|
331
|
+
|
|
332
|
+
Inject `Pagination` as a dependency — reads `?page` and `?limit` from the query string.
|
|
333
|
+
|
|
334
|
+
```python
|
|
335
|
+
from forgeapi.pagination import Pagination
|
|
336
|
+
|
|
337
|
+
@route.get("/posts")
|
|
338
|
+
async def list_posts(self, pagination: Pagination) -> dict:
|
|
339
|
+
total = await Post.all().count()
|
|
340
|
+
items = await Post.all().offset(pagination.offset).limit(pagination.limit)
|
|
341
|
+
return {"items": items, "total": total, "page": pagination.page, "limit": pagination.limit}
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
| Attribute | Description |
|
|
345
|
+
|---|---|
|
|
346
|
+
| `pagination.page` | Current page (1-based) |
|
|
347
|
+
| `pagination.limit` | Items per page (capped at `max_limit`) |
|
|
348
|
+
| `pagination.offset` | SQL offset = `(page - 1) * limit` |
|
|
349
|
+
|
|
350
|
+
```toml
|
|
351
|
+
[pagination]
|
|
352
|
+
default_limit = 20
|
|
353
|
+
max_limit = 100
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
Or configure via `Core`:
|
|
357
|
+
|
|
358
|
+
```python
|
|
359
|
+
Core(app, pagination=20) # default_limit=20, max_limit from toml
|
|
360
|
+
Core(app, pagination=True) # both from toml
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
---
|
|
364
|
+
|
|
365
|
+
## 6. Events
|
|
366
|
+
|
|
367
|
+
Events decouple side effects (emails, notifications, cache) from business logic.
|
|
368
|
+
|
|
369
|
+
### Defining events
|
|
370
|
+
|
|
371
|
+
```python
|
|
372
|
+
# app/events/order_created_event.py
|
|
373
|
+
from forgeapi import Event
|
|
374
|
+
|
|
375
|
+
class OrderCreated(Event):
|
|
376
|
+
background = True # True = fire-and-forget; False = await before response
|
|
377
|
+
|
|
378
|
+
def __init__(self, order_id: int, total: float) -> None:
|
|
379
|
+
self.order_id = order_id
|
|
380
|
+
self.total = total
|
|
381
|
+
```
|
|
382
|
+
|
|
383
|
+
`background = True` — listeners run in `asyncio.create_task`, response is returned immediately.
|
|
384
|
+
`background = False` (default) — all listeners are awaited before the response.
|
|
385
|
+
|
|
386
|
+
### @listen decorator
|
|
387
|
+
|
|
388
|
+
```python
|
|
389
|
+
# app/listeners/order_listener.py
|
|
390
|
+
from forgeapi import listen
|
|
391
|
+
from app.events.order_created_event import OrderCreated
|
|
392
|
+
|
|
393
|
+
@listen(OrderCreated)
|
|
394
|
+
async def send_confirmation(event: OrderCreated) -> None:
|
|
395
|
+
await mailer.send(f"Order #{event.order_id} total: {event.total}")
|
|
396
|
+
|
|
397
|
+
@listen(OrderCreated)
|
|
398
|
+
async def update_inventory(event: OrderCreated) -> None:
|
|
399
|
+
await Inventory.decrease(order_id=event.order_id)
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
Multiple listeners for the same event run **in parallel** via `asyncio.gather`.
|
|
403
|
+
Individual listener exceptions are logged but do **not** propagate to the route.
|
|
404
|
+
|
|
405
|
+
### Dispatching
|
|
406
|
+
|
|
407
|
+
```python
|
|
408
|
+
@route.post("/orders")
|
|
409
|
+
async def create(self, payload: OrderCreatePayload, user: CurrentUser) -> OrderResponse:
|
|
410
|
+
order = await Order.create(**payload.model_dump(), user_id=int(user.id))
|
|
411
|
+
await OrderCreated(order_id=order.id, total=order.total).dispatch()
|
|
412
|
+
return OrderResponse.model_validate(order)
|
|
413
|
+
```
|
|
414
|
+
|
|
415
|
+
### EventBus
|
|
416
|
+
|
|
417
|
+
`Core(app, events=True)` calls `EventBus.load_from_dir("app/listeners")` which imports every `*.py` file in the directory. `@listen` registers on import — no manual wiring needed.
|
|
418
|
+
|
|
419
|
+
```python
|
|
420
|
+
from forgeapi import EventBus
|
|
421
|
+
|
|
422
|
+
# manual registration (without decorator)
|
|
423
|
+
bus = EventBus.get_instance()
|
|
424
|
+
bus.register(OrderCreated, my_async_handler)
|
|
425
|
+
|
|
426
|
+
# inspect registered listeners
|
|
427
|
+
listeners = bus.listeners_for(OrderCreated)
|
|
428
|
+
|
|
429
|
+
# reset (useful in tests)
|
|
430
|
+
EventBus.reset()
|
|
431
|
+
```
|
|
432
|
+
|
|
433
|
+
Test fixture:
|
|
434
|
+
|
|
435
|
+
```python
|
|
436
|
+
import pytest
|
|
437
|
+
from forgeapi import EventBus
|
|
438
|
+
|
|
439
|
+
@pytest.fixture(autouse=True)
|
|
440
|
+
def reset_bus():
|
|
441
|
+
EventBus.reset()
|
|
442
|
+
yield
|
|
443
|
+
EventBus.reset()
|
|
444
|
+
```
|
|
445
|
+
|
|
446
|
+
---
|
|
447
|
+
|
|
448
|
+
## 7. Controllers
|
|
449
|
+
|
|
450
|
+
Controllers are classes that group routes. `Core` auto-discovers all `*_controller.py` files in `controllers_dir` (recursively) and registers their routers under `base_prefix`.
|
|
451
|
+
|
|
452
|
+
### Base pattern
|
|
453
|
+
|
|
454
|
+
```python
|
|
455
|
+
# app/controllers/post_controller.py
|
|
456
|
+
from forgeapi.controllers import Controller, route
|
|
457
|
+
from forgeapi.auth import CurrentUser
|
|
458
|
+
from forgeapi.pagination import Pagination
|
|
459
|
+
from app.models import Post
|
|
460
|
+
from app.schemas.response.post import PostResponse
|
|
461
|
+
from app.schemas.payload.post import PostCreatePayload, PostUpdatePayload
|
|
462
|
+
|
|
463
|
+
class PostController(Controller):
|
|
464
|
+
prefix = "/posts"
|
|
465
|
+
tags = ["posts"]
|
|
466
|
+
|
|
467
|
+
@route.get("/")
|
|
468
|
+
async def index(self, pagination: Pagination) -> dict:
|
|
469
|
+
total = await Post.all().count()
|
|
470
|
+
items = await Post.all().offset(pagination.offset).limit(pagination.limit)
|
|
471
|
+
return {"items": [PostResponse.model_validate(p) for p in items], "total": total}
|
|
472
|
+
|
|
473
|
+
@route.post("/", response_model=PostResponse, status_code=201)
|
|
474
|
+
async def create(self, payload: PostCreatePayload, user: CurrentUser) -> PostResponse:
|
|
475
|
+
post = await Post.create(**payload.model_dump(), author_id=int(user.id))
|
|
476
|
+
return PostResponse.model_validate(post)
|
|
477
|
+
|
|
478
|
+
@route.get("/{post_id}", response_model=PostResponse)
|
|
479
|
+
async def show(self, post_id: int) -> PostResponse:
|
|
480
|
+
post = await Post.get_or_none(id=post_id)
|
|
481
|
+
if not post:
|
|
482
|
+
raise HTTPException(404, "Not found")
|
|
483
|
+
return PostResponse.model_validate(post)
|
|
484
|
+
|
|
485
|
+
@route.patch("/{post_id}", response_model=PostResponse)
|
|
486
|
+
async def update(self, post_id: int, payload: PostUpdatePayload, user: CurrentUser) -> PostResponse:
|
|
487
|
+
post = await Post.get_or_none(id=post_id, author_id=int(user.id))
|
|
488
|
+
if not post:
|
|
489
|
+
raise HTTPException(404, "Not found or not yours")
|
|
490
|
+
for field, value in payload.model_dump(exclude_none=True).items():
|
|
491
|
+
setattr(post, field, value)
|
|
492
|
+
await post.save()
|
|
493
|
+
return PostResponse.model_validate(post)
|
|
494
|
+
|
|
495
|
+
@route.delete("/{post_id}")
|
|
496
|
+
async def destroy(self, post_id: int, user: CurrentUser) -> dict:
|
|
497
|
+
post = await Post.get_or_none(id=post_id, author_id=int(user.id))
|
|
498
|
+
if not post:
|
|
499
|
+
raise HTTPException(404, "Not found or not yours")
|
|
500
|
+
await post.delete()
|
|
501
|
+
return {"detail": "deleted"}
|
|
502
|
+
```
|
|
503
|
+
|
|
504
|
+
### Route decorator
|
|
505
|
+
|
|
506
|
+
```python
|
|
507
|
+
from forgeapi.controllers import Controller, route
|
|
508
|
+
|
|
509
|
+
# shorthand — preferred
|
|
510
|
+
@route.get("/")
|
|
511
|
+
@route.post("/")
|
|
512
|
+
@route.put("/{id}")
|
|
513
|
+
@route.patch("/{id}")
|
|
514
|
+
@route.delete("/{id}")
|
|
515
|
+
|
|
516
|
+
# explicit form — still works, supports multiple methods
|
|
517
|
+
@route("/", methods=["GET"])
|
|
518
|
+
@route("/{id}", methods=["PATCH", "PUT"])
|
|
519
|
+
```
|
|
520
|
+
|
|
521
|
+
All kwargs are forwarded to FastAPI:
|
|
522
|
+
|
|
523
|
+
```python
|
|
524
|
+
@route.post("/", response_model=PostResponse, status_code=201, summary="Create post",
|
|
525
|
+
dependencies=[Depends(some_dep)])
|
|
526
|
+
async def create(self, payload: PostCreatePayload) -> PostResponse: ...
|
|
527
|
+
```
|
|
528
|
+
|
|
529
|
+
### Auto-prefix and namespace
|
|
530
|
+
|
|
531
|
+
If `prefix` is not set, it is derived from the class name. Every CamelCase word before the last becomes a URL segment; the last word is pluralised:
|
|
532
|
+
|
|
533
|
+
| Class | Auto prefix |
|
|
534
|
+
|---|---|
|
|
535
|
+
| `UserController` | `/users` |
|
|
536
|
+
| `AdminUserController` | `/admin/users` |
|
|
537
|
+
| `ApiV1PostController` | `/api/v1/posts` |
|
|
538
|
+
| `SuperAdminOrderItemController` | `/super/admin/order/items` |
|
|
539
|
+
|
|
540
|
+
Namespace controllers are generated into subdirectories:
|
|
541
|
+
|
|
542
|
+
```bash
|
|
543
|
+
forgeapi make:controller AdminUser # controllers/admin/user_controller.py
|
|
544
|
+
forgeapi make:controller ApiV1Post # controllers/api/v1/post_controller.py
|
|
545
|
+
```
|
|
546
|
+
|
|
547
|
+
```
|
|
548
|
+
controllers/
|
|
549
|
+
user_controller.py
|
|
550
|
+
admin/
|
|
551
|
+
__init__.py
|
|
552
|
+
user_controller.py # AdminUserController → /admin/users
|
|
553
|
+
api/
|
|
554
|
+
__init__.py
|
|
555
|
+
v1/
|
|
556
|
+
__init__.py
|
|
557
|
+
post_controller.py # ApiV1PostController → /api/v1/posts
|
|
558
|
+
```
|
|
559
|
+
|
|
560
|
+
`Core` discovers all of these automatically via recursive glob.
|
|
561
|
+
|
|
562
|
+
---
|
|
563
|
+
|
|
564
|
+
## 8. Schemas
|
|
565
|
+
|
|
566
|
+
### Base classes
|
|
567
|
+
|
|
568
|
+
```python
|
|
569
|
+
from forgeapi import BaseSchema, BaseCreateSchema, BaseUpdateSchema
|
|
570
|
+
```
|
|
571
|
+
|
|
572
|
+
**`BaseSchema`** — response schemas. Adds `id: int`, `created_at: datetime`, `updated_at: datetime`. Has `from_attributes=True` so it reads directly from Tortoise model instances.
|
|
573
|
+
|
|
574
|
+
```python
|
|
575
|
+
class PostResponse(BaseSchema):
|
|
576
|
+
title: str
|
|
577
|
+
body: str
|
|
578
|
+
|
|
579
|
+
return PostResponse.model_validate(post)
|
|
580
|
+
```
|
|
581
|
+
|
|
582
|
+
**`BaseCreateSchema`** — POST payloads. Plain `BaseModel` subclass.
|
|
583
|
+
|
|
584
|
+
```python
|
|
585
|
+
class PostCreatePayload(BaseCreateSchema):
|
|
586
|
+
title: str
|
|
587
|
+
body: str
|
|
588
|
+
is_published: bool = True
|
|
589
|
+
```
|
|
590
|
+
|
|
591
|
+
**`BaseUpdateSchema`** — PATCH payloads. Plain `BaseModel` subclass. Convention: all fields `Optional`.
|
|
592
|
+
|
|
593
|
+
```python
|
|
594
|
+
class PostUpdatePayload(BaseUpdateSchema):
|
|
595
|
+
title: str | None = None
|
|
596
|
+
body: str | None = None
|
|
597
|
+
|
|
598
|
+
# applying a partial update:
|
|
599
|
+
for field, value in payload.model_dump(exclude_none=True).items():
|
|
600
|
+
setattr(post, field, value)
|
|
601
|
+
await post.save()
|
|
602
|
+
```
|
|
603
|
+
|
|
604
|
+
### Schema directories
|
|
605
|
+
|
|
606
|
+
Recommended layout:
|
|
607
|
+
|
|
608
|
+
```
|
|
609
|
+
schemas/
|
|
610
|
+
payload/
|
|
611
|
+
__init__.py
|
|
612
|
+
post.py # PostCreatePayload, PostGetPayload, PostUpdatePayload
|
|
613
|
+
user.py # UserCreatePayload, UserGetPayload, UserUpdatePayload
|
|
614
|
+
response/
|
|
615
|
+
__init__.py
|
|
616
|
+
post.py # PostResponse, PostListResponse
|
|
617
|
+
user.py # UserResponse, UserListResponse
|
|
618
|
+
```
|
|
619
|
+
|
|
620
|
+
### generate:schema
|
|
621
|
+
|
|
622
|
+
Generate typed schemas from an existing Tortoise model by reading `_meta.fields_map` at runtime.
|
|
623
|
+
|
|
624
|
+
```bash
|
|
625
|
+
forgeapi generate:schema Post --payload # cru by default
|
|
626
|
+
forgeapi generate:schema Post --response # Response + ListResponse
|
|
627
|
+
forgeapi generate:schema Post --payload --response # both
|
|
628
|
+
forgeapi generate:schema Post --payload -crud # all four payload classes
|
|
629
|
+
forgeapi generate:schema Post --payload --cu # Create + Update only
|
|
630
|
+
```
|
|
631
|
+
|
|
632
|
+
**`--payload`** output:
|
|
633
|
+
|
|
634
|
+
| CRUD flag | Class | Base |
|
|
635
|
+
|---|---|---|
|
|
636
|
+
| `c` | `PostCreatePayload` | `BaseCreateSchema` |
|
|
637
|
+
| `r` | `PostGetPayload` | `BaseModel` (all Optional, for filtering) |
|
|
638
|
+
| `u` | `PostUpdatePayload` | `BaseUpdateSchema` |
|
|
639
|
+
| `d` | `PostDeletePayload` | `BaseModel` |
|
|
640
|
+
|
|
641
|
+
Default when `--payload` is given without CRUD flags: `cru` (no delete).
|
|
642
|
+
Use `-d` or `-crud` to include delete.
|
|
643
|
+
|
|
644
|
+
**`--response`** always generates exactly:
|
|
645
|
+
|
|
646
|
+
```python
|
|
647
|
+
class PostResponse(BaseSchema):
|
|
648
|
+
title: str # real types from the model
|
|
649
|
+
body: str
|
|
650
|
+
...
|
|
651
|
+
|
|
652
|
+
class PostListResponse(BaseModel):
|
|
653
|
+
items: list[PostResponse]
|
|
654
|
+
total: int
|
|
655
|
+
```
|
|
656
|
+
|
|
657
|
+
If the model isn't found, `pass` stubs are generated — the command still succeeds.
|
|
658
|
+
|
|
659
|
+
---
|
|
660
|
+
|
|
661
|
+
## 9. Permissions
|
|
662
|
+
|
|
663
|
+
Spatie-style roles and permissions using **polymorphic pivot tables**. Any number of models can have roles and permissions without creating extra junction tables per model.
|
|
664
|
+
|
|
665
|
+
### How it works
|
|
666
|
+
|
|
667
|
+
Instead of `user_roles` / `user_permissions` per model, two shared tables store all assignments:
|
|
668
|
+
|
|
669
|
+
```
|
|
670
|
+
model_has_roles model_has_permissions
|
|
671
|
+
────────────────── ──────────────────────
|
|
672
|
+
model_type = "user" model_type = "user"
|
|
673
|
+
model_id = 42 model_id = 42
|
|
674
|
+
role_id = 1 permission_id = 3
|
|
675
|
+
```
|
|
676
|
+
|
|
677
|
+
`model_type` is the lowercase class name. Adding permissions to a new model (e.g. `Team`) requires zero new migrations — it reuses the same two tables.
|
|
678
|
+
|
|
679
|
+
> The permission models register under the `models` app — no separate `permissions` app needed in your config.
|
|
680
|
+
|
|
681
|
+
### DB tables
|
|
682
|
+
|
|
683
|
+
| Table | Description |
|
|
684
|
+
|---|---|
|
|
685
|
+
| `permissions` | Permission records (`id`, `name`, `guard`) |
|
|
686
|
+
| `roles` | Role records (`id`, `name`, `guard`) |
|
|
687
|
+
| `role_permissions` | Role ↔ Permission M2M |
|
|
688
|
+
| `model_has_roles` | Polymorphic — `model_type`, `model_id`, `role_id` |
|
|
689
|
+
| `model_has_permissions` | Polymorphic — `model_type`, `model_id`, `permission_id` |
|
|
690
|
+
|
|
691
|
+
---
|
|
692
|
+
|
|
693
|
+
### Setup
|
|
694
|
+
|
|
695
|
+
**1. Add `PermissionsMixin` to your model:**
|
|
696
|
+
|
|
697
|
+
```python
|
|
698
|
+
# database/models/user.py
|
|
699
|
+
from tortoise import fields
|
|
700
|
+
from forgeapi.permissions import PermissionsMixin
|
|
701
|
+
|
|
702
|
+
class User(PermissionsMixin):
|
|
703
|
+
id = fields.IntField(pk=True)
|
|
704
|
+
username = fields.CharField(max_length=150, unique=True)
|
|
705
|
+
email = fields.CharField(max_length=255, unique=True)
|
|
706
|
+
|
|
707
|
+
class Meta:
|
|
708
|
+
table = "users"
|
|
709
|
+
```
|
|
710
|
+
|
|
711
|
+
`PermissionsMixin` is `abstract = True` — it adds no columns and no junction tables to `users`. All assignments are stored in the shared polymorphic pivots.
|
|
712
|
+
|
|
713
|
+
**2. Add permissions models to your Tortoise config:**
|
|
714
|
+
|
|
715
|
+
```python
|
|
716
|
+
# app/config.py
|
|
717
|
+
TORTOISE_ORM = {
|
|
718
|
+
"apps": {
|
|
719
|
+
"models": {
|
|
720
|
+
"models": ["database.models", "forgeapi.permissions.models"], # ← add here
|
|
721
|
+
"default_connection": "default",
|
|
722
|
+
"migrations": "database.migrations",
|
|
723
|
+
},
|
|
724
|
+
},
|
|
725
|
+
...
|
|
726
|
+
}
|
|
727
|
+
```
|
|
728
|
+
|
|
729
|
+
**3. Register in `Core`:**
|
|
730
|
+
|
|
731
|
+
```python
|
|
732
|
+
from database.models import User
|
|
733
|
+
|
|
734
|
+
core = Core(app, auth=True, permissions=User)
|
|
735
|
+
```
|
|
736
|
+
|
|
737
|
+
**4. Run migrations:**
|
|
738
|
+
|
|
739
|
+
```bash
|
|
740
|
+
forgeapi db:makemigrations && forgeapi db:migrate
|
|
741
|
+
```
|
|
742
|
+
|
|
743
|
+
---
|
|
744
|
+
|
|
745
|
+
### PermissionsMixin — all methods
|
|
746
|
+
|
|
747
|
+
All methods are `async`.
|
|
748
|
+
|
|
749
|
+
#### Checking permissions
|
|
750
|
+
|
|
751
|
+
```python
|
|
752
|
+
await user.can("edit:posts") # True if has ANY of the given perms (direct or via role)
|
|
753
|
+
await user.can("edit:posts", "admin") # True if has ANY one of the two
|
|
754
|
+
await user.cannot("delete:users") # inverse of can()
|
|
755
|
+
await user.has_all_permissions("read", "write") # True only if has ALL
|
|
756
|
+
|
|
757
|
+
await user.get_all_permissions()
|
|
758
|
+
# → ["edit:posts", "admin", ...] direct + via roles, deduplicated
|
|
759
|
+
```
|
|
760
|
+
|
|
761
|
+
#### Granting / revoking permissions
|
|
762
|
+
|
|
763
|
+
```python
|
|
764
|
+
await user.give_permission("edit:posts", "delete:posts")
|
|
765
|
+
await user.revoke_permission("delete:posts", "edit:posts") # one or many
|
|
766
|
+
await user.sync_permissions(["read:posts", "edit:posts"]) # replaces all direct perms
|
|
767
|
+
```
|
|
768
|
+
|
|
769
|
+
#### Checking roles
|
|
770
|
+
|
|
771
|
+
```python
|
|
772
|
+
await user.has_role("admin") # True if has ANY of the given roles
|
|
773
|
+
await user.has_role("admin", "editor") # True if has ANY one
|
|
774
|
+
await user.has_all_roles("admin", "editor") # True only if has ALL
|
|
775
|
+
|
|
776
|
+
await user.get_role_names() # → ["admin", "editor"]
|
|
777
|
+
```
|
|
778
|
+
|
|
779
|
+
#### Assigning / removing roles
|
|
780
|
+
|
|
781
|
+
```python
|
|
782
|
+
await user.assign_role("admin", "editor")
|
|
783
|
+
await user.remove_role("editor", "viewer") # one or many
|
|
784
|
+
await user.sync_roles(["admin"]) # replaces all roles
|
|
785
|
+
```
|
|
786
|
+
|
|
787
|
+
---
|
|
788
|
+
|
|
789
|
+
### Dependencies
|
|
790
|
+
|
|
791
|
+
Enforce access control in route handlers. Both return the DB user instance on success or raise `403`.
|
|
792
|
+
|
|
793
|
+
```python
|
|
794
|
+
from forgeapi.permissions import RequirePermission, RequireRole
|
|
795
|
+
```
|
|
796
|
+
|
|
797
|
+
**`RequirePermission(*permissions)`** — user must have **at least one**:
|
|
798
|
+
|
|
799
|
+
```python
|
|
800
|
+
@route.delete("/{id}")
|
|
801
|
+
async def destroy(self, id: int, user=RequirePermission("delete:posts")):
|
|
802
|
+
...
|
|
803
|
+
|
|
804
|
+
@route.post("/")
|
|
805
|
+
async def create(self, payload: PostCreatePayload, user=RequirePermission("create:posts", "admin")):
|
|
806
|
+
...
|
|
807
|
+
```
|
|
808
|
+
|
|
809
|
+
**`RequireRole(*roles)`** — user must have **at least one**:
|
|
810
|
+
|
|
811
|
+
```python
|
|
812
|
+
@route.get("/admin/stats")
|
|
813
|
+
async def stats(self, user=RequireRole("admin")):
|
|
814
|
+
...
|
|
815
|
+
|
|
816
|
+
@route.get("/dashboard")
|
|
817
|
+
async def dashboard(self, user=RequireRole("admin", "moderator")):
|
|
818
|
+
...
|
|
819
|
+
```
|
|
820
|
+
|
|
821
|
+
---
|
|
822
|
+
|
|
823
|
+
### Role model
|
|
824
|
+
|
|
825
|
+
`Role` itself can have permissions — useful for bulk assignment.
|
|
826
|
+
|
|
827
|
+
```python
|
|
828
|
+
from forgeapi.permissions.models import Role, Permission
|
|
829
|
+
|
|
830
|
+
role = await Role.find_or_create("editor")
|
|
831
|
+
|
|
832
|
+
await role.give_permission("edit:posts", "read:posts")
|
|
833
|
+
await role.revoke_permission("read:posts")
|
|
834
|
+
await role.sync_permissions(["edit:posts"])
|
|
835
|
+
await role.has_permission("edit:posts") # → bool
|
|
836
|
+
|
|
837
|
+
# assigning a role gives the user all permissions of that role
|
|
838
|
+
await user.assign_role("editor")
|
|
839
|
+
await user.can("edit:posts") # → True (via role)
|
|
840
|
+
```
|
|
841
|
+
|
|
842
|
+
---
|
|
843
|
+
|
|
844
|
+
## 10. Middleware
|
|
845
|
+
|
|
846
|
+
Two extension points: **global middleware** wraps every request, **guards** are scoped to a route or controller via DI.
|
|
847
|
+
|
|
848
|
+
---
|
|
849
|
+
|
|
850
|
+
### Custom global middleware
|
|
851
|
+
|
|
852
|
+
Subclass `Middleware`, override `dispatch` — the standard Starlette hook. `call_next` passes the request to the handler and returns the response.
|
|
853
|
+
|
|
854
|
+
```python
|
|
855
|
+
from forgeapi import Middleware
|
|
856
|
+
from fastapi import Request, Response
|
|
857
|
+
from typing import Callable
|
|
858
|
+
|
|
859
|
+
class TimingMiddleware(Middleware):
|
|
860
|
+
async def dispatch(self, request: Request, call_next: Callable) -> Response:
|
|
861
|
+
import time
|
|
862
|
+
start = time.perf_counter()
|
|
863
|
+
response = await call_next(request)
|
|
864
|
+
response.headers["X-Process-Time"] = f"{time.perf_counter() - start:.3f}s"
|
|
865
|
+
return response
|
|
866
|
+
```
|
|
867
|
+
|
|
868
|
+
**Register:**
|
|
869
|
+
|
|
870
|
+
```python
|
|
871
|
+
# at Core init
|
|
872
|
+
core = Core(app, middleware=[TimingMiddleware])
|
|
873
|
+
|
|
874
|
+
# multiple, with kwargs via tuple
|
|
875
|
+
core = Core(app, middleware=[
|
|
876
|
+
TimingMiddleware,
|
|
877
|
+
(TenantMiddleware, {"default_tenant": "acme"}),
|
|
878
|
+
])
|
|
879
|
+
|
|
880
|
+
# after init — chainable
|
|
881
|
+
core.use(TimingMiddleware)
|
|
882
|
+
core.use(TenantMiddleware, default_tenant="acme")
|
|
883
|
+
```
|
|
884
|
+
|
|
885
|
+
Use `request.state` to pass data to downstream handlers:
|
|
886
|
+
|
|
887
|
+
```python
|
|
888
|
+
class TenantMiddleware(Middleware):
|
|
889
|
+
def __init__(self, app, default_tenant: str = "public"):
|
|
890
|
+
super().__init__(app)
|
|
891
|
+
self.default_tenant = default_tenant
|
|
892
|
+
|
|
893
|
+
async def dispatch(self, request: Request, call_next: Callable) -> Response:
|
|
894
|
+
request.state.tenant = request.headers.get("X-Tenant", self.default_tenant)
|
|
895
|
+
return await call_next(request)
|
|
896
|
+
```
|
|
897
|
+
|
|
898
|
+
To short-circuit the request without reaching the handler:
|
|
899
|
+
|
|
900
|
+
```python
|
|
901
|
+
from fastapi.responses import JSONResponse
|
|
902
|
+
|
|
903
|
+
class MaintenanceMiddleware(Middleware):
|
|
904
|
+
async def dispatch(self, request: Request, call_next: Callable) -> Response:
|
|
905
|
+
if request.headers.get("X-Bypass") != "secret":
|
|
906
|
+
return JSONResponse({"detail": "Under maintenance"}, status_code=503)
|
|
907
|
+
return await call_next(request)
|
|
908
|
+
```
|
|
909
|
+
|
|
910
|
+
---
|
|
911
|
+
|
|
912
|
+
### Guards — per-route / per-controller
|
|
913
|
+
|
|
914
|
+
Guards are FastAPI dependencies. They run before the handler and raise `HTTPException` to block access. Unlike global middleware they can target a single route or a whole controller.
|
|
915
|
+
|
|
916
|
+
Subclass `Guard` and override `handle`. FastAPI injects parameters declared in `handle` automatically — the same way as a regular route handler. `__call__` is internal and mirrors `handle`'s signature at class creation time.
|
|
917
|
+
|
|
918
|
+
```python
|
|
919
|
+
from forgeapi import Guard
|
|
920
|
+
from fastapi import HTTPException, Request
|
|
921
|
+
|
|
922
|
+
class ApiKeyGuard(Guard):
|
|
923
|
+
def __init__(self, header: str = "X-API-Key"):
|
|
924
|
+
self.header = header
|
|
925
|
+
|
|
926
|
+
async def handle(self, request: Request) -> None:
|
|
927
|
+
if not request.headers.get(self.header):
|
|
928
|
+
raise HTTPException(403, "Missing API key")
|
|
929
|
+
```
|
|
930
|
+
|
|
931
|
+
**Per-route:**
|
|
932
|
+
|
|
933
|
+
```python
|
|
934
|
+
from fastapi import Depends
|
|
935
|
+
|
|
936
|
+
class PostController(Controller):
|
|
937
|
+
@route.post("/", dependencies=[Depends(ApiKeyGuard())])
|
|
938
|
+
async def create(self, payload: PostCreatePayload): ...
|
|
939
|
+
```
|
|
940
|
+
|
|
941
|
+
**Per-controller** — `guards` applies to every route in the class:
|
|
942
|
+
|
|
943
|
+
```python
|
|
944
|
+
class AdminController(Controller):
|
|
945
|
+
prefix = "/admin"
|
|
946
|
+
guards = [ApiKeyGuard("X-Admin-Key")]
|
|
947
|
+
|
|
948
|
+
@route.get("/stats")
|
|
949
|
+
async def stats(self): ...
|
|
950
|
+
|
|
951
|
+
@route.get("/users")
|
|
952
|
+
async def users(self): ...
|
|
953
|
+
```
|
|
954
|
+
|
|
955
|
+
Declare FastAPI dependencies directly in `handle` — they are injected automatically:
|
|
956
|
+
|
|
957
|
+
```python
|
|
958
|
+
from forgeapi.auth import CurrentUser
|
|
959
|
+
|
|
960
|
+
class ActiveUserGuard(Guard):
|
|
961
|
+
async def handle(self, user: CurrentUser) -> None:
|
|
962
|
+
if not user.is_active:
|
|
963
|
+
raise HTTPException(403, "Account disabled")
|
|
964
|
+
|
|
965
|
+
class AdminController(Controller):
|
|
966
|
+
guards = [ActiveUserGuard()]
|
|
967
|
+
```
|
|
968
|
+
|
|
969
|
+
Mix `Guard` instances and raw `Depends` in `guards`:
|
|
970
|
+
|
|
971
|
+
```python
|
|
972
|
+
class AdminController(Controller):
|
|
973
|
+
guards = [
|
|
974
|
+
ActiveUserGuard(), # auto-wrapped in Depends
|
|
975
|
+
Depends(require_admin_role), # raw Depends — used as-is
|
|
976
|
+
]
|
|
977
|
+
```
|
|
978
|
+
|
|
979
|
+
---
|
|
980
|
+
|
|
981
|
+
### Built-in middleware
|
|
982
|
+
|
|
983
|
+
Configured via `Core` keyword arguments.
|
|
984
|
+
|
|
985
|
+
| Argument | Default | Description |
|
|
986
|
+
|---|---|---|
|
|
987
|
+
| `cors` | `False` | `["*"]` or list of origins |
|
|
988
|
+
| `rate_limit` | `False` | `True` = 60 req/min; int = custom limit |
|
|
989
|
+
| `request_id` | `False` | Injects `X-Request-ID` header |
|
|
990
|
+
| `logging` | `True` | Logs method, path, status, duration |
|
|
991
|
+
|
|
992
|
+
#### CORS
|
|
993
|
+
|
|
994
|
+
```python
|
|
995
|
+
Core(app, cors=["*"])
|
|
996
|
+
Core(app, cors=["https://example.com", "https://app.example.com"])
|
|
997
|
+
```
|
|
998
|
+
|
|
999
|
+
#### Rate limiting
|
|
1000
|
+
|
|
1001
|
+
Sliding window per IP. Returns `429` with `Retry-After` header.
|
|
1002
|
+
|
|
1003
|
+
```python
|
|
1004
|
+
Core(app, rate_limit=True) # 60 req/min
|
|
1005
|
+
Core(app, rate_limit=200) # 200 req/min
|
|
1006
|
+
```
|
|
1007
|
+
|
|
1008
|
+
```json
|
|
1009
|
+
{"success": false, "error": {"code": "RATE_LIMITED", "message": "Too many requests. Slow down."}}
|
|
1010
|
+
```
|
|
1011
|
+
|
|
1012
|
+
#### Request ID
|
|
1013
|
+
|
|
1014
|
+
```python
|
|
1015
|
+
Core(app, request_id=True)
|
|
1016
|
+
```
|
|
1017
|
+
|
|
1018
|
+
Access in a route or downstream middleware via `request.state.request_id`.
|
|
1019
|
+
|
|
1020
|
+
#### Access logging
|
|
1021
|
+
|
|
1022
|
+
Logger name: `forgeapi.access`. Format: `GET /api/v1/users → 200 [12.3ms] req_id=abc`.
|
|
1023
|
+
|
|
1024
|
+
```python
|
|
1025
|
+
Core(app, logging=False) # disable
|
|
1026
|
+
|
|
1027
|
+
import logging
|
|
1028
|
+
logging.getLogger("forgeapi.access").setLevel(logging.WARNING)
|
|
1029
|
+
```
|
|
1030
|
+
|
|
1031
|
+
---
|
|
1032
|
+
|
|
1033
|
+
## 11. Settings
|
|
1034
|
+
|
|
1035
|
+
`BaseAppSettings` wraps `pydantic-settings` with `.env` file loading out of the box.
|
|
1036
|
+
|
|
1037
|
+
```python
|
|
1038
|
+
from forgeapi.settings import BaseAppSettings
|
|
1039
|
+
|
|
1040
|
+
class Settings(BaseAppSettings):
|
|
1041
|
+
database_url: str
|
|
1042
|
+
redis_url: str | None = None
|
|
1043
|
+
jwt_secret: str
|
|
1044
|
+
debug: bool = False
|
|
1045
|
+
app_name: str = "My App" # overrides the default
|
|
1046
|
+
|
|
1047
|
+
settings = Settings() # reads .env automatically
|
|
1048
|
+
```
|
|
1049
|
+
|
|
1050
|
+
`.env` file:
|
|
1051
|
+
|
|
1052
|
+
```bash
|
|
1053
|
+
DATABASE_URL=postgresql://user:pass@localhost/mydb
|
|
1054
|
+
JWT_SECRET=supersecret
|
|
1055
|
+
DEBUG=true
|
|
1056
|
+
```
|
|
1057
|
+
|
|
1058
|
+
`BaseAppSettings` already has `debug: bool = False` and `app_name: str = "FastAPI App"`.
|
|
1059
|
+
All env vars are case-insensitive. Unknown vars are ignored (`extra="ignore"`).
|
|
1060
|
+
|
|
1061
|
+
---
|
|
1062
|
+
|
|
1063
|
+
## 12. CLI reference
|
|
1064
|
+
|
|
1065
|
+
Add `-h` after any command for detailed help:
|
|
1066
|
+
|
|
1067
|
+
```bash
|
|
1068
|
+
forgeapi make:controller -h
|
|
1069
|
+
forgeapi generate:schema -h
|
|
1070
|
+
forgeapi make -H # list all make: variants
|
|
1071
|
+
```
|
|
1072
|
+
|
|
1073
|
+
---
|
|
1074
|
+
|
|
1075
|
+
### `forgeapi init <project-name>`
|
|
1076
|
+
|
|
1077
|
+
Scaffold a new project.
|
|
1078
|
+
|
|
1079
|
+
```bash
|
|
1080
|
+
forgeapi init my-blog
|
|
1081
|
+
```
|
|
1082
|
+
|
|
1083
|
+
Asks for:
|
|
1084
|
+
- Auth strategy: `jwt` / `cookie` / `telegram`
|
|
1085
|
+
- DB driver: `asyncpg` / `aiosqlite` / `aiomysql`
|
|
1086
|
+
- Welcome boilerplate: User + Post + events (y/n)
|
|
1087
|
+
|
|
1088
|
+
---
|
|
1089
|
+
|
|
1090
|
+
### `forgeapi make:controller <Name> [flags]`
|
|
1091
|
+
|
|
1092
|
+
Generate a controller. CamelCase namespace supported.
|
|
1093
|
+
|
|
1094
|
+
```bash
|
|
1095
|
+
forgeapi make:controller User
|
|
1096
|
+
forgeapi make:controller User --ms # + model + stub schemas
|
|
1097
|
+
forgeapi make:controller AdminUser # controllers/admin/user_controller.py
|
|
1098
|
+
forgeapi make:controller ApiV1Post --ms
|
|
1099
|
+
```
|
|
1100
|
+
|
|
1101
|
+
| Flag | Short | Generates |
|
|
1102
|
+
|---|---|---|
|
|
1103
|
+
| `--model` | `-m` | Tortoise model |
|
|
1104
|
+
| `--schema` | `-s` | Stub schemas |
|
|
1105
|
+
|
|
1106
|
+
Compound: `--ms` `--mc` `--mcs` `-ms` `-cs` etc.
|
|
1107
|
+
|
|
1108
|
+
---
|
|
1109
|
+
|
|
1110
|
+
### `forgeapi make:model <Name> [flags]`
|
|
1111
|
+
|
|
1112
|
+
```bash
|
|
1113
|
+
forgeapi make:model Post
|
|
1114
|
+
forgeapi make:model Post -cs # + controller + schema
|
|
1115
|
+
```
|
|
1116
|
+
|
|
1117
|
+
| Flag | Short | Generates |
|
|
1118
|
+
|---|---|---|
|
|
1119
|
+
| `--controller` | `-c` | Controller |
|
|
1120
|
+
| `--schema` | `-s` | Stub schemas |
|
|
1121
|
+
|
|
1122
|
+
---
|
|
1123
|
+
|
|
1124
|
+
### `forgeapi make:schema <Name> [flags]`
|
|
1125
|
+
|
|
1126
|
+
Generate stub schemas (3 classes with `pass`). For typed schemas from an existing model use `generate:schema`.
|
|
1127
|
+
|
|
1128
|
+
```bash
|
|
1129
|
+
forgeapi make:schema Post
|
|
1130
|
+
forgeapi make:schema Post --mc # + model + controller
|
|
1131
|
+
```
|
|
1132
|
+
|
|
1133
|
+
---
|
|
1134
|
+
|
|
1135
|
+
### `forgeapi make:event <Name>`
|
|
1136
|
+
|
|
1137
|
+
```bash
|
|
1138
|
+
forgeapi make:event UserRegistered
|
|
1139
|
+
# → app/events/user_registered_event.py
|
|
1140
|
+
```
|
|
1141
|
+
|
|
1142
|
+
---
|
|
1143
|
+
|
|
1144
|
+
### `forgeapi make:listener <Name>`
|
|
1145
|
+
|
|
1146
|
+
```bash
|
|
1147
|
+
forgeapi make:listener UserRegistered
|
|
1148
|
+
# → app/listeners/user_registered_listener.py
|
|
1149
|
+
```
|
|
1150
|
+
|
|
1151
|
+
---
|
|
1152
|
+
|
|
1153
|
+
### `forgeapi generate:schema <Name> --payload | --response [crud]`
|
|
1154
|
+
|
|
1155
|
+
Generate typed schemas from an existing Tortoise model. At least one of `--payload` / `--response` required.
|
|
1156
|
+
|
|
1157
|
+
```bash
|
|
1158
|
+
forgeapi generate:schema User --payload # CreatePayload + GetPayload + UpdatePayload
|
|
1159
|
+
forgeapi generate:schema User --response # UserResponse + UserListResponse
|
|
1160
|
+
forgeapi generate:schema User --payload --response # both
|
|
1161
|
+
forgeapi generate:schema User --payload -crud # all four incl. DeletePayload
|
|
1162
|
+
forgeapi generate:schema User --payload --cu # Create + Update only
|
|
1163
|
+
```
|
|
1164
|
+
|
|
1165
|
+
CRUD flags (`--payload` only):
|
|
1166
|
+
|
|
1167
|
+
| Flag | Operations |
|
|
1168
|
+
|---|---|
|
|
1169
|
+
| `--crud` | c + r + u (default — no delete) |
|
|
1170
|
+
| `-crud` | c + r + u + d (all four) |
|
|
1171
|
+
| `--cu` / `-cu` | create + update |
|
|
1172
|
+
| `--cr` / `-cr` | create + read |
|
|
1173
|
+
| `-d` | delete only |
|
|
1174
|
+
|
|
1175
|
+
`--response` ignores CRUD flags — always generates `{Name}Response` + `{Name}ListResponse`.
|
|
1176
|
+
|
|
1177
|
+
---
|
|
1178
|
+
|
|
1179
|
+
### `forgeapi runserver [options]`
|
|
1180
|
+
|
|
1181
|
+
```bash
|
|
1182
|
+
forgeapi runserver
|
|
1183
|
+
forgeapi runserver --reload
|
|
1184
|
+
forgeapi runserver --port 9000 --host 0.0.0.0 --reload
|
|
1185
|
+
```
|
|
1186
|
+
|
|
1187
|
+
---
|
|
1188
|
+
|
|
1189
|
+
### `forgeapi db:<subcommand>`
|
|
1190
|
+
|
|
1191
|
+
```bash
|
|
1192
|
+
forgeapi db:init
|
|
1193
|
+
forgeapi db:makemigrations
|
|
1194
|
+
forgeapi db:makemigrations -n add_email_field
|
|
1195
|
+
forgeapi db:migrate
|
|
1196
|
+
forgeapi db:downgrade
|
|
1197
|
+
forgeapi db:history
|
|
1198
|
+
```
|
|
1199
|
+
|
|
1200
|
+
---
|
|
1201
|
+
|
|
1202
|
+
## 13. forgeapi.toml reference
|
|
1203
|
+
|
|
1204
|
+
```toml
|
|
1205
|
+
[project]
|
|
1206
|
+
name = "my-app"
|
|
1207
|
+
version = "0.1.0"
|
|
1208
|
+
|
|
1209
|
+
[structure]
|
|
1210
|
+
models_dir = "app/models"
|
|
1211
|
+
controllers_dir = "app/controllers"
|
|
1212
|
+
schemas_dir = "app/schemas"
|
|
1213
|
+
events_dir = "app/events"
|
|
1214
|
+
listeners_dir = "app/listeners"
|
|
1215
|
+
base_prefix = "/api/v1"
|
|
1216
|
+
|
|
1217
|
+
[auth]
|
|
1218
|
+
strategy = "jwt" # jwt | cookie | telegram
|
|
1219
|
+
jwt_secret_env = "JWT_SECRET" # name of env var holding the secret
|
|
1220
|
+
access_ttl_minutes = 30
|
|
1221
|
+
refresh_ttl_days = 7
|
|
1222
|
+
|
|
1223
|
+
# cookie-only:
|
|
1224
|
+
cookie_name = "session"
|
|
1225
|
+
cookie_httponly = true
|
|
1226
|
+
cookie_secure = false # set true in production (HTTPS)
|
|
1227
|
+
|
|
1228
|
+
[pagination]
|
|
1229
|
+
default_limit = 20
|
|
1230
|
+
max_limit = 100
|
|
1231
|
+
```
|
|
1232
|
+
|
|
1233
|
+
All fields are optional — `Core` works without a config file using the defaults above.
|