symfonia-ai-tools 1.7.1 → 1.8.0
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.
- package/README.md +23 -0
- package/lib/questions.mjs +4 -3
- package/package.json +1 -1
- package/templates/packs/python-fastapi/_ai/instructions/api-schema.instructions.md +177 -0
- package/templates/packs/python-fastapi/_ai/instructions/module.instructions.md +211 -0
- package/templates/packs/python-fastapi/_ai/instructions/service-repository.instructions.md +363 -0
- package/templates/packs/python-fastapi/_ai/instructions/testing.instructions.md +295 -0
- package/templates/packs/python-fastapi/_ai/skills/smf-background-task/SKILL.md +244 -0
- package/templates/packs/python-fastapi/_ai/skills/smf-migration/SKILL.md +215 -0
- package/templates/packs/python-fastapi/_ai/skills/smf-new-endpoint/SKILL.md +188 -0
- package/templates/packs/python-fastapi/_ai/skills/smf-new-module/SKILL.md +303 -0
- package/templates/packs/python-fastapi/_ai/skills/smf-testing-integration/SKILL.md +218 -0
- package/templates/packs/python-fastapi/_ai/skills/smf-testing-manual/SKILL.md +254 -0
- package/templates/packs/python-fastapi/_ai/skills/smf-testing-unit/SKILL.md +215 -0
- package/templates/packs/python-fastapi/_guidelines.md +45 -0
- package/templates/packs/python-fastapi/pack.json +8 -0
package/README.md
CHANGED
|
@@ -73,6 +73,7 @@ Instead of choosing a single stack, you pick any combination of instruction & sk
|
|
|
73
73
|
| **Vitest** | 1 instruction | Yes |
|
|
74
74
|
| **Storybook** | 1 instruction | No |
|
|
75
75
|
| **Laravel + PHP** | 4 instructions, 7 skills | No |
|
|
76
|
+
| **Python FastAPI** | 4 instructions, 7 skills | No |
|
|
76
77
|
| **Playwright E2E** | 1 instruction, 3 skills | No |
|
|
77
78
|
| **Docker** | 1 instruction | No |
|
|
78
79
|
|
|
@@ -155,6 +156,15 @@ When AI opens/edits a file matching `applyTo`, it automatically loads those inst
|
|
|
155
156
|
| `api-resource.instructions.md` | `**/*Resource*.php, **/*Request*.php` | Resources with whenLoaded(), Form Requests, Policies |
|
|
156
157
|
| `testing.instructions.md` | `**/*Test*.php, **/tests/**` | PHPUnit, data providers, factories, Laravel fakes |
|
|
157
158
|
|
|
159
|
+
### Python FastAPI (4 instructions)
|
|
160
|
+
|
|
161
|
+
| Instruction | applyTo | What it teaches AI |
|
|
162
|
+
|-------------|---------|-------------------|
|
|
163
|
+
| `api-schema.instructions.md` | `**/schemas.py, **/*schema*.py, **/*request*.py, **/*response*.py` | Pydantic v2 schemas, StrEnum status fields, `PaginatedResponse[T]`, frozen request models, `ErrorResponse` |
|
|
164
|
+
| `module.instructions.md` | `**/modules/**/router.py, **/modules/**/dependencies.py, **/modules/**/domain/**, **/modules/**/infrastructure/**` | Module structure, FastAPI router pattern, SQLAlchemy 2.0 ORM models, `lifespan=` setup, DI wiring |
|
|
165
|
+
| `service-repository.instructions.md` | `**/domain/services.py, **/infrastructure/repositories/**, **/domain/entities.py, **/domain/value_objects.py` | CQRS repos, domain entities, EventPublisher pattern, layer isolation rules |
|
|
166
|
+
| `testing.instructions.md` | `**/tests/**, **/*test*.py, **/conftest.py` | pytest-asyncio conftest, `async_sessionmaker`, rollback pattern, `AsyncClient` |
|
|
167
|
+
|
|
158
168
|
### Playwright (1 instruction)
|
|
159
169
|
|
|
160
170
|
| Instruction | applyTo | What it teaches AI |
|
|
@@ -267,6 +277,18 @@ All skills have the `smf-` prefix (Symfonia). The configurator lets you choose w
|
|
|
267
277
|
| `smf-testing-unit` | Unit tests | Isolated tests without DB, PHPUnit mocks, data providers |
|
|
268
278
|
| `smf-testing-manual` | Manual HTTP tests | JetBrains HTTP Client — `.http` files for interactive API testing |
|
|
269
279
|
|
|
280
|
+
### Python FastAPI skills (7 skills)
|
|
281
|
+
|
|
282
|
+
| Skill | Purpose | When to use |
|
|
283
|
+
|-------|---------|-------------|
|
|
284
|
+
| `smf-new-module` | New module from scratch | router → schemas → ORM model → CQRS repos → service → migration → tests |
|
|
285
|
+
| `smf-new-endpoint` | New endpoint in existing module | request schema → route → service method → response schema → tests |
|
|
286
|
+
| `smf-migration` | Alembic migration | autogenerate → review → upgrade → verify; async env.py setup |
|
|
287
|
+
| `smf-background-task` | Celery background task | task def (sync) → `SyncSessionLocal` → dispatch from router → beat schedule → tests |
|
|
288
|
+
| `smf-testing-integration` | Integration tests (HTTP) | `AsyncClient` → real DB via rollback conftest → router assertions |
|
|
289
|
+
| `smf-testing-unit` | Unit tests (service) | mock repos → service logic → no DB needed |
|
|
290
|
+
| `smf-testing-manual` | Manual HTTP tests | JetBrains HTTP Client `.http` files for interactive API testing |
|
|
291
|
+
|
|
270
292
|
### Playwright skills (3 skills)
|
|
271
293
|
|
|
272
294
|
| Skill | Purpose | When to use |
|
|
@@ -524,6 +546,7 @@ templates/
|
|
|
524
546
|
├── vitest/ # 1 instr.
|
|
525
547
|
├── storybook/ # 1 instr.
|
|
526
548
|
├── laravel/ # 4 instr. · 7 skills
|
|
549
|
+
├── python-fastapi/ # 4 instr. · 7 skills
|
|
527
550
|
├── playwright/ # 1 instr. · 3 skills
|
|
528
551
|
└── docker/ # 1 instr.
|
|
529
552
|
```
|
package/lib/questions.mjs
CHANGED
|
@@ -156,9 +156,10 @@ export async function askQuestions(packsDir) {
|
|
|
156
156
|
|
|
157
157
|
const hasVue = answers.packs.includes('vue3');
|
|
158
158
|
const hasLaravel = answers.packs.includes('laravel');
|
|
159
|
-
|
|
160
|
-
answers.
|
|
161
|
-
answers.
|
|
159
|
+
const hasPythonFastapi = answers.packs.includes('python-fastapi');
|
|
160
|
+
answers.testCommand = await ask(t('q.cmd.test'), hasVue ? 'npm run test' : hasLaravel ? 'php artisan test' : hasPythonFastapi ? 'pytest' : 'npm test');
|
|
161
|
+
answers.buildCommand = await ask(t('q.cmd.build'), hasVue ? 'npm run build' : hasLaravel ? 'composer build' : hasPythonFastapi ? 'docker build .' : 'npm run build');
|
|
162
|
+
answers.lintCommand = await ask(t('q.cmd.lint'), hasVue ? 'npm run lint' : hasLaravel ? 'php artisan pint' : hasPythonFastapi ? 'ruff check . && mypy .' : 'npm run lint');
|
|
162
163
|
|
|
163
164
|
// --- CI ---
|
|
164
165
|
section(t('q.section.ci'));
|
package/package.json
CHANGED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
---
|
|
2
|
+
applyTo: "**/schemas.py,**/*schema*.py,**/*request*.py,**/*response*.py"
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
# API Schemas, Request & Response Models — Instructions
|
|
6
|
+
|
|
7
|
+
## Pydantic v2 Schemas
|
|
8
|
+
|
|
9
|
+
Never return ORM models directly from endpoints. All input/output passes through Pydantic schemas.
|
|
10
|
+
|
|
11
|
+
```python
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from datetime import datetime
|
|
15
|
+
from uuid import UUID
|
|
16
|
+
|
|
17
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class FeatureResponse(BaseModel):
|
|
21
|
+
model_config = ConfigDict(from_attributes=True)
|
|
22
|
+
|
|
23
|
+
id: UUID
|
|
24
|
+
name: str
|
|
25
|
+
description: str | None
|
|
26
|
+
status: FeatureStatus # use the StrEnum — not bare `str`
|
|
27
|
+
created_at: datetime
|
|
28
|
+
|
|
29
|
+
# Nested relations — use None default (loaded on demand)
|
|
30
|
+
author: UserResponse | None = None
|
|
31
|
+
category: CategoryResponse | None = None
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
### Request Schemas (Input Validation)
|
|
35
|
+
|
|
36
|
+
Request schemas are **read-only once validated** — add `frozen=True` to catch accidental mutation:
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
from __future__ import annotations
|
|
40
|
+
|
|
41
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
|
42
|
+
from uuid import UUID
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class StoreFeatureRequest(BaseModel):
|
|
46
|
+
model_config = ConfigDict(frozen=True)
|
|
47
|
+
|
|
48
|
+
name: str = Field(min_length=2, max_length=255)
|
|
49
|
+
description: str | None = Field(default=None, max_length=5000)
|
|
50
|
+
category_id: UUID | None = None
|
|
51
|
+
status: FeatureStatus
|
|
52
|
+
tags: list[str] = Field(default_factory=list)
|
|
53
|
+
|
|
54
|
+
@field_validator("tags")
|
|
55
|
+
@classmethod
|
|
56
|
+
def validate_tags(cls, v: list[str]) -> list[str]:
|
|
57
|
+
if any(len(tag) > 50 for tag in v):
|
|
58
|
+
raise ValueError("Each tag must be at most 50 characters")
|
|
59
|
+
return v
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class UpdateFeatureRequest(BaseModel):
|
|
63
|
+
model_config = ConfigDict(frozen=True)
|
|
64
|
+
|
|
65
|
+
name: str | None = Field(default=None, min_length=2, max_length=255)
|
|
66
|
+
description: str | None = None
|
|
67
|
+
status: FeatureStatus | None = None
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### List / Index Request with Filtering and Sorting
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
from __future__ import annotations
|
|
74
|
+
|
|
75
|
+
from typing import Literal
|
|
76
|
+
from pydantic import BaseModel, Field
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class IndexFeatureRequest(BaseModel):
|
|
80
|
+
page: int = Field(default=1, ge=1)
|
|
81
|
+
limit: int = Field(default=20, ge=1, le=100)
|
|
82
|
+
status: FeatureStatus | None = None
|
|
83
|
+
sort_by: Literal["created_at", "name", "status"] = "created_at"
|
|
84
|
+
sort_dir: Literal["asc", "desc"] = "desc"
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### Paginated Collection Response
|
|
88
|
+
|
|
89
|
+
`PaginatedResponse` and `PaginationMeta` are **shared types** — define them once in `app/core/schemas.py`, never per-module:
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
# app/core/schemas.py
|
|
93
|
+
from __future__ import annotations
|
|
94
|
+
|
|
95
|
+
from typing import Generic, TypeVar
|
|
96
|
+
from pydantic import BaseModel
|
|
97
|
+
|
|
98
|
+
T = TypeVar("T")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class PaginationMeta(BaseModel):
|
|
102
|
+
total: int
|
|
103
|
+
per_page: int
|
|
104
|
+
current_page: int
|
|
105
|
+
last_page: int
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class PaginatedResponse(BaseModel, Generic[T]):
|
|
109
|
+
data: list[T]
|
|
110
|
+
meta: PaginationMeta
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Import from `app.core.schemas` in every module that needs it.
|
|
114
|
+
|
|
115
|
+
## Error Responses
|
|
116
|
+
|
|
117
|
+
Standardize the error body shape. Define `ErrorResponse` in `app/core/schemas.py`:
|
|
118
|
+
|
|
119
|
+
```python
|
|
120
|
+
# app/core/schemas.py
|
|
121
|
+
class ErrorResponse(BaseModel):
|
|
122
|
+
code: str # machine-readable code: "FEATURE_NOT_FOUND"
|
|
123
|
+
message: str # human-readable description
|
|
124
|
+
field: str | None = None # which field caused the error (optional)
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Raise via `HTTPException` — the exception handler converts it:
|
|
128
|
+
|
|
129
|
+
```python
|
|
130
|
+
from fastapi import HTTPException, status
|
|
131
|
+
|
|
132
|
+
# In service (expected domain errors):
|
|
133
|
+
raise HTTPException(
|
|
134
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
135
|
+
detail={"code": "FEATURE_NOT_FOUND", "message": "Feature not found"},
|
|
136
|
+
)
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Register a handler in `app/main.py` to enforce consistent shape:
|
|
140
|
+
|
|
141
|
+
```python
|
|
142
|
+
from fastapi import Request
|
|
143
|
+
from fastapi.responses import JSONResponse
|
|
144
|
+
|
|
145
|
+
@app.exception_handler(HTTPException)
|
|
146
|
+
async def http_exception_handler(request: Request, exc: HTTPException) -> JSONResponse:
|
|
147
|
+
if isinstance(exc.detail, dict):
|
|
148
|
+
body = exc.detail
|
|
149
|
+
else:
|
|
150
|
+
body = {"code": "HTTP_ERROR", "message": str(exc.detail)}
|
|
151
|
+
return JSONResponse(status_code=exc.status_code, content=body)
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
## Enums
|
|
155
|
+
|
|
156
|
+
```python
|
|
157
|
+
from enum import StrEnum
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class FeatureStatus(StrEnum):
|
|
161
|
+
DRAFT = "draft"
|
|
162
|
+
ACTIVE = "active"
|
|
163
|
+
ARCHIVED = "archived"
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
Use `StrEnum` (Python 3.11+) — auto-serializes to string in JSON.
|
|
167
|
+
|
|
168
|
+
## Rules
|
|
169
|
+
|
|
170
|
+
- `from_attributes=True` on every response schema that reads from ORM
|
|
171
|
+
- Never expose internal IDs as integers — use UUID
|
|
172
|
+
- Use `Field(...)` for constraints, not just annotations
|
|
173
|
+
- `StrEnum` for all enums — clean JSON serialization, use in both request and response schemas
|
|
174
|
+
- Nested relations are always `| None = None` — populate only when loaded
|
|
175
|
+
- `frozen=True` on all request/input schemas — prevents accidental mutation after validation
|
|
176
|
+
- `PaginatedResponse[T]` and `PaginationMeta` live in `app/core/schemas.py` — import, never redefine
|
|
177
|
+
- `ErrorResponse` body has `code` (machine) and `message` (human) — register a global exception handler
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
---
|
|
2
|
+
applyTo: "**/modules/**/router.py,**/modules/**/dependencies.py,**/modules/**/domain/**,**/modules/**/infrastructure/**"
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
# Modular Architecture — Instructions
|
|
6
|
+
|
|
7
|
+
## Module Structure
|
|
8
|
+
|
|
9
|
+
Each business domain has its own directory under `modules/`:
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
modules/feature/
|
|
13
|
+
├── __init__.py
|
|
14
|
+
├── router.py # FastAPI APIRouter — thin, no logic
|
|
15
|
+
├── dependencies.py # Depends() factories for DI
|
|
16
|
+
├── domain/
|
|
17
|
+
│ ├── __init__.py
|
|
18
|
+
│ ├── entities.py # Domain entities (pure Python dataclasses)
|
|
19
|
+
│ ├── schemas.py # Pydantic v2 request/response schemas
|
|
20
|
+
│ ├── services.py # Business logic
|
|
21
|
+
│ ├── events.py # [optional] Domain events (dataclasses)
|
|
22
|
+
│ └── value_objects.py # [optional] Value objects (frozen dataclasses)
|
|
23
|
+
├── infrastructure/
|
|
24
|
+
│ ├── __init__.py
|
|
25
|
+
│ ├── orm.py # SQLAlchemy ORM mapped classes
|
|
26
|
+
│ ├── repositories/
|
|
27
|
+
│ │ ├── __init__.py
|
|
28
|
+
│ │ ├── base.py # Abstract base classes (interfaces)
|
|
29
|
+
│ │ ├── command_repository.py # SQLAlchemy write implementation
|
|
30
|
+
│ │ └── query_repository.py # SQLAlchemy read implementation
|
|
31
|
+
│ └── tasks.py # [optional] Celery tasks for this module
|
|
32
|
+
└── tests/
|
|
33
|
+
├── __init__.py
|
|
34
|
+
├── conftest.py # Module-level fixtures
|
|
35
|
+
├── integration/
|
|
36
|
+
│ ├── __init__.py
|
|
37
|
+
│ └── test_feature_router.py
|
|
38
|
+
└── unit/
|
|
39
|
+
├── __init__.py
|
|
40
|
+
└── test_feature_service.py
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
> **Optional files** (`events.py`, `value_objects.py`, `tasks.py`) — create only when needed. Do not scaffold files that will stay empty.
|
|
44
|
+
|
|
45
|
+
## Router (`router.py`)
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
from __future__ import annotations
|
|
49
|
+
|
|
50
|
+
from uuid import UUID
|
|
51
|
+
|
|
52
|
+
from fastapi import APIRouter, Depends, status
|
|
53
|
+
|
|
54
|
+
from app.core.auth import get_current_user
|
|
55
|
+
from modules.feature.dependencies import get_feature_service
|
|
56
|
+
from modules.feature.domain.schemas import (
|
|
57
|
+
FeatureResponse,
|
|
58
|
+
IndexFeatureRequest,
|
|
59
|
+
PaginatedResponse,
|
|
60
|
+
StoreFeatureRequest,
|
|
61
|
+
UpdateFeatureRequest,
|
|
62
|
+
)
|
|
63
|
+
from modules.feature.domain.services import FeatureService
|
|
64
|
+
|
|
65
|
+
router = APIRouter(prefix="/features", tags=["features"])
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@router.get("", response_model=PaginatedResponse[FeatureResponse])
|
|
69
|
+
async def index(
|
|
70
|
+
params: IndexFeatureRequest = Depends(),
|
|
71
|
+
current_user=Depends(get_current_user),
|
|
72
|
+
service: FeatureService = Depends(get_feature_service),
|
|
73
|
+
) -> PaginatedResponse[FeatureResponse]:
|
|
74
|
+
return await service.list_for_user(current_user.id, params)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@router.post("", response_model=FeatureResponse, status_code=status.HTTP_201_CREATED)
|
|
78
|
+
async def store(
|
|
79
|
+
body: StoreFeatureRequest,
|
|
80
|
+
current_user=Depends(get_current_user),
|
|
81
|
+
service: FeatureService = Depends(get_feature_service),
|
|
82
|
+
) -> FeatureResponse:
|
|
83
|
+
return await service.create(current_user.id, body)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@router.get("/{feature_id}", response_model=FeatureResponse)
|
|
87
|
+
async def show(
|
|
88
|
+
feature_id: UUID,
|
|
89
|
+
current_user=Depends(get_current_user),
|
|
90
|
+
service: FeatureService = Depends(get_feature_service),
|
|
91
|
+
) -> FeatureResponse:
|
|
92
|
+
return await service.get_or_404(feature_id, current_user.id)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@router.patch("/{feature_id}", response_model=FeatureResponse)
|
|
96
|
+
async def update(
|
|
97
|
+
feature_id: UUID,
|
|
98
|
+
body: UpdateFeatureRequest,
|
|
99
|
+
current_user=Depends(get_current_user),
|
|
100
|
+
service: FeatureService = Depends(get_feature_service),
|
|
101
|
+
) -> FeatureResponse:
|
|
102
|
+
return await service.update(feature_id, current_user.id, body)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@router.delete("/{feature_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
106
|
+
async def destroy(
|
|
107
|
+
feature_id: UUID,
|
|
108
|
+
current_user=Depends(get_current_user),
|
|
109
|
+
service: FeatureService = Depends(get_feature_service),
|
|
110
|
+
) -> None:
|
|
111
|
+
await service.delete(feature_id, current_user.id)
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## Dependencies (`dependencies.py`)
|
|
115
|
+
|
|
116
|
+
```python
|
|
117
|
+
from __future__ import annotations
|
|
118
|
+
|
|
119
|
+
from fastapi import Depends
|
|
120
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
121
|
+
|
|
122
|
+
from app.core.database import get_db
|
|
123
|
+
from modules.feature.domain.services import FeatureService
|
|
124
|
+
from modules.feature.infrastructure.repositories.command_repository import (
|
|
125
|
+
SqlAlchemyFeatureCommandRepository,
|
|
126
|
+
)
|
|
127
|
+
from modules.feature.infrastructure.repositories.query_repository import (
|
|
128
|
+
SqlAlchemyFeatureQueryRepository,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def get_feature_service(
|
|
133
|
+
db: AsyncSession = Depends(get_db),
|
|
134
|
+
) -> FeatureService:
|
|
135
|
+
return FeatureService(
|
|
136
|
+
command_repo=SqlAlchemyFeatureCommandRepository(db),
|
|
137
|
+
query_repo=SqlAlchemyFeatureQueryRepository(db),
|
|
138
|
+
)
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
## Router Registration (`app/main.py`)
|
|
142
|
+
|
|
143
|
+
```python
|
|
144
|
+
from contextlib import asynccontextmanager
|
|
145
|
+
from fastapi import FastAPI
|
|
146
|
+
from app.core.config import settings
|
|
147
|
+
from modules.feature.router import router as feature_router
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@asynccontextmanager
|
|
151
|
+
async def lifespan(app: FastAPI):
|
|
152
|
+
# startup
|
|
153
|
+
yield
|
|
154
|
+
# shutdown
|
|
155
|
+
|
|
156
|
+
app = FastAPI(lifespan=lifespan)
|
|
157
|
+
app.include_router(feature_router, prefix=f"/api/{settings.API_VERSION}")
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
> Use `lifespan=` context manager (FastAPI 0.95+). The deprecated `@app.on_event("startup")` pattern must not be used.
|
|
161
|
+
|
|
162
|
+
## ORM Model (`infrastructure/orm.py`)
|
|
163
|
+
|
|
164
|
+
```python
|
|
165
|
+
from __future__ import annotations
|
|
166
|
+
|
|
167
|
+
import uuid
|
|
168
|
+
from datetime import datetime
|
|
169
|
+
|
|
170
|
+
from sqlalchemy import UUID as SA_UUID
|
|
171
|
+
from sqlalchemy import DateTime, Enum, ForeignKey, String, Text, func
|
|
172
|
+
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
173
|
+
|
|
174
|
+
from app.core.database import Base
|
|
175
|
+
from modules.feature.domain.entities import FeatureStatus
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
class FeatureORM(Base):
|
|
179
|
+
__tablename__ = "features"
|
|
180
|
+
|
|
181
|
+
id: Mapped[uuid.UUID] = mapped_column(
|
|
182
|
+
SA_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
|
183
|
+
)
|
|
184
|
+
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
185
|
+
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
186
|
+
status: Mapped[FeatureStatus] = mapped_column(
|
|
187
|
+
Enum(FeatureStatus), nullable=False, default=FeatureStatus.DRAFT
|
|
188
|
+
)
|
|
189
|
+
user_id: Mapped[uuid.UUID] = mapped_column(
|
|
190
|
+
SA_UUID(as_uuid=True), ForeignKey("users.id"), nullable=False, index=True
|
|
191
|
+
)
|
|
192
|
+
created_at: Mapped[datetime] = mapped_column(
|
|
193
|
+
DateTime(timezone=True), server_default=func.now(), nullable=False
|
|
194
|
+
)
|
|
195
|
+
deleted_at: Mapped[datetime | None] = mapped_column(
|
|
196
|
+
DateTime(timezone=True), nullable=True
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
user: Mapped["UserORM"] = relationship("UserORM", lazy="noload")
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
## Rules
|
|
203
|
+
|
|
204
|
+
- Routers are thin — one line per handler, everything delegated to service
|
|
205
|
+
- `Depends()` factory per module in `dependencies.py`
|
|
206
|
+
- ORM models live in `infrastructure/orm.py`, never in `domain/`
|
|
207
|
+
- Domain entities are plain dataclasses — no SQLAlchemy imports in `domain/`
|
|
208
|
+
- Abstract repository interfaces live in `infrastructure/repositories/base.py`; SQLAlchemy implementations are separate files in the same folder
|
|
209
|
+
- Register all routers in `app/main.py` with versioned prefix
|
|
210
|
+
- Use `server_default=func.now()` for `created_at` — not Python-side `datetime.now()` in ORM defaults
|
|
211
|
+
- Do NOT scaffold `events.py`, `value_objects.py`, or `tasks.py` unless the module actually needs them
|