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.
Files changed (60) hide show
  1. forge_kits-0.1.0.dist-info/METADATA +1233 -0
  2. forge_kits-0.1.0.dist-info/RECORD +60 -0
  3. forge_kits-0.1.0.dist-info/WHEEL +4 -0
  4. forge_kits-0.1.0.dist-info/entry_points.txt +2 -0
  5. forgeapi/__init__.py +61 -0
  6. forgeapi/auth/__init__.py +42 -0
  7. forgeapi/auth/backend.py +186 -0
  8. forgeapi/auth/models.py +18 -0
  9. forgeapi/auth/strategies/__init__.py +6 -0
  10. forgeapi/auth/strategies/base.py +21 -0
  11. forgeapi/auth/strategies/cookie.py +169 -0
  12. forgeapi/auth/strategies/jwt.py +157 -0
  13. forgeapi/auth/strategies/telegram.py +139 -0
  14. forgeapi/cli/__init__.py +3 -0
  15. forgeapi/cli/commands/__init__.py +0 -0
  16. forgeapi/cli/commands/boilerplate_cmd.py +689 -0
  17. forgeapi/cli/commands/db_cmd.py +28 -0
  18. forgeapi/cli/commands/fresh_cmd.py +81 -0
  19. forgeapi/cli/commands/generate_cmd.py +277 -0
  20. forgeapi/cli/commands/generate_schema_cmd.py +321 -0
  21. forgeapi/cli/commands/init_cmd.py +312 -0
  22. forgeapi/cli/commands/models_cmd.py +70 -0
  23. forgeapi/cli/commands/routers_cmd.py +77 -0
  24. forgeapi/cli/commands/runserver_cmd.py +43 -0
  25. forgeapi/cli/commands/seed_cmd.py +115 -0
  26. forgeapi/cli/main.py +402 -0
  27. forgeapi/cli/templates/controller.py.jinja2 +16 -0
  28. forgeapi/cli/templates/event.py.jinja2 +8 -0
  29. forgeapi/cli/templates/listener.py.jinja2 +7 -0
  30. forgeapi/cli/templates/model.py.jinja2 +11 -0
  31. forgeapi/cli/templates/schema.py.jinja2 +13 -0
  32. forgeapi/cli/templates/seeder.py.jinja2 +6 -0
  33. forgeapi/config.py +63 -0
  34. forgeapi/controllers/__init__.py +3 -0
  35. forgeapi/controllers/base.py +99 -0
  36. forgeapi/database/__init__.py +3 -0
  37. forgeapi/database/seeder.py +26 -0
  38. forgeapi/events/__init__.py +5 -0
  39. forgeapi/events/bus.py +185 -0
  40. forgeapi/events/decorators.py +53 -0
  41. forgeapi/events/event.py +61 -0
  42. forgeapi/kit.py +256 -0
  43. forgeapi/middleware/__init__.py +15 -0
  44. forgeapi/middleware/base_middleware.py +42 -0
  45. forgeapi/middleware/cors.py +18 -0
  46. forgeapi/middleware/guard.py +61 -0
  47. forgeapi/middleware/logging.py +26 -0
  48. forgeapi/middleware/rate_limit.py +37 -0
  49. forgeapi/middleware/request_id.py +14 -0
  50. forgeapi/pagination/__init__.py +3 -0
  51. forgeapi/pagination/paginator.py +77 -0
  52. forgeapi/permissions/__init__.py +14 -0
  53. forgeapi/permissions/dependencies.py +66 -0
  54. forgeapi/permissions/mixins.py +178 -0
  55. forgeapi/permissions/models.py +96 -0
  56. forgeapi/permissions/registry.py +30 -0
  57. forgeapi/schemas/__init__.py +3 -0
  58. forgeapi/schemas/base.py +41 -0
  59. forgeapi/settings/__init__.py +3 -0
  60. 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.