@malamute/ai-rules 1.0.0 → 1.2.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 +270 -121
- package/bin/cli.js +5 -2
- package/configs/_shared/.claude/rules/conventions/documentation.md +324 -0
- package/configs/_shared/.claude/rules/conventions/git.md +265 -0
- package/configs/_shared/.claude/rules/{performance.md → conventions/performance.md} +1 -1
- package/configs/_shared/.claude/rules/conventions/principles.md +334 -0
- package/configs/_shared/.claude/rules/devops/ci-cd.md +262 -0
- package/configs/_shared/.claude/rules/devops/docker.md +275 -0
- package/configs/_shared/.claude/rules/devops/nx.md +194 -0
- package/configs/_shared/.claude/rules/domain/backend/api-design.md +203 -0
- package/configs/_shared/.claude/rules/lang/csharp/async.md +220 -0
- package/configs/_shared/.claude/rules/lang/csharp/csharp.md +314 -0
- package/configs/_shared/.claude/rules/lang/csharp/linq.md +210 -0
- package/configs/_shared/.claude/rules/lang/python/async.md +337 -0
- package/configs/_shared/.claude/rules/lang/python/celery.md +476 -0
- package/configs/_shared/.claude/rules/lang/python/config.md +339 -0
- package/configs/{python/.claude/rules → _shared/.claude/rules/lang/python}/database/sqlalchemy.md +6 -1
- package/configs/_shared/.claude/rules/lang/python/deployment.md +523 -0
- package/configs/_shared/.claude/rules/lang/python/error-handling.md +330 -0
- package/configs/_shared/.claude/rules/lang/python/migrations.md +421 -0
- package/configs/_shared/.claude/rules/lang/python/python.md +172 -0
- package/configs/_shared/.claude/rules/lang/python/repository.md +383 -0
- package/configs/{python/.claude/rules → _shared/.claude/rules/lang/python}/testing.md +2 -69
- package/configs/_shared/.claude/rules/lang/typescript/async.md +447 -0
- package/configs/_shared/.claude/rules/lang/typescript/generics.md +356 -0
- package/configs/_shared/.claude/rules/lang/typescript/typescript.md +212 -0
- package/configs/_shared/.claude/rules/quality/error-handling.md +48 -0
- package/configs/_shared/.claude/rules/quality/logging.md +45 -0
- package/configs/_shared/.claude/rules/quality/observability.md +240 -0
- package/configs/_shared/.claude/rules/quality/testing-patterns.md +65 -0
- package/configs/_shared/.claude/rules/security/secrets-management.md +222 -0
- package/configs/_shared/.claude/skills/analysis/explore/SKILL.md +257 -0
- package/configs/_shared/.claude/skills/analysis/security-audit/SKILL.md +184 -0
- package/configs/_shared/.claude/skills/dev/api-endpoint/SKILL.md +126 -0
- package/configs/_shared/.claude/{commands/generate-tests.md → skills/dev/generate-tests/SKILL.md} +6 -0
- package/configs/_shared/.claude/{commands/fix-issue.md → skills/git/fix-issue/SKILL.md} +6 -0
- package/configs/_shared/.claude/{commands/review-pr.md → skills/git/review-pr/SKILL.md} +6 -0
- package/configs/_shared/.claude/skills/infra/deploy/SKILL.md +139 -0
- package/configs/_shared/.claude/skills/infra/docker/SKILL.md +95 -0
- package/configs/_shared/.claude/skills/infra/migration/SKILL.md +158 -0
- package/configs/_shared/.claude/skills/nx/nx-affected/SKILL.md +72 -0
- package/configs/_shared/.claude/skills/nx/nx-lib/SKILL.md +375 -0
- package/configs/_shared/CLAUDE.md +52 -149
- package/configs/angular/.claude/rules/{components.md → core/components.md} +69 -15
- package/configs/angular/.claude/rules/core/resource.md +285 -0
- package/configs/angular/.claude/rules/core/signals.md +323 -0
- package/configs/angular/.claude/rules/http.md +338 -0
- package/configs/angular/.claude/rules/routing.md +291 -0
- package/configs/angular/.claude/rules/ssr.md +312 -0
- package/configs/angular/.claude/rules/state/signal-store.md +408 -0
- package/configs/angular/.claude/rules/{state.md → state/state.md} +2 -2
- package/configs/angular/.claude/rules/testing.md +7 -7
- package/configs/angular/.claude/rules/ui/aria.md +422 -0
- package/configs/angular/.claude/rules/ui/forms.md +424 -0
- package/configs/angular/.claude/rules/ui/pipes-directives.md +335 -0
- package/configs/angular/.claude/settings.json +1 -0
- package/configs/angular/.claude/skills/ngrx-slice/SKILL.md +362 -0
- package/configs/angular/.claude/skills/signal-store/SKILL.md +445 -0
- package/configs/angular/CLAUDE.md +24 -216
- package/configs/dotnet/.claude/rules/background-services.md +552 -0
- package/configs/dotnet/.claude/rules/configuration.md +426 -0
- package/configs/dotnet/.claude/rules/ddd.md +447 -0
- package/configs/dotnet/.claude/rules/dependency-injection.md +343 -0
- package/configs/dotnet/.claude/rules/mediatr.md +320 -0
- package/configs/dotnet/.claude/rules/middleware.md +489 -0
- package/configs/dotnet/.claude/rules/result-pattern.md +363 -0
- package/configs/dotnet/.claude/rules/validation.md +388 -0
- package/configs/dotnet/.claude/settings.json +21 -3
- package/configs/dotnet/CLAUDE.md +53 -286
- package/configs/fastapi/.claude/rules/background-tasks.md +254 -0
- package/configs/fastapi/.claude/rules/dependencies.md +170 -0
- package/configs/{python → fastapi}/.claude/rules/fastapi.md +61 -1
- package/configs/fastapi/.claude/rules/lifespan.md +274 -0
- package/configs/fastapi/.claude/rules/middleware.md +229 -0
- package/configs/fastapi/.claude/rules/pydantic.md +433 -0
- package/configs/fastapi/.claude/rules/responses.md +251 -0
- package/configs/fastapi/.claude/rules/routers.md +202 -0
- package/configs/fastapi/.claude/rules/security.md +222 -0
- package/configs/fastapi/.claude/rules/testing.md +251 -0
- package/configs/fastapi/.claude/rules/websockets.md +298 -0
- package/configs/fastapi/.claude/settings.json +33 -0
- package/configs/fastapi/CLAUDE.md +144 -0
- package/configs/flask/.claude/rules/blueprints.md +208 -0
- package/configs/flask/.claude/rules/cli.md +285 -0
- package/configs/flask/.claude/rules/configuration.md +281 -0
- package/configs/flask/.claude/rules/context.md +238 -0
- package/configs/flask/.claude/rules/error-handlers.md +278 -0
- package/configs/flask/.claude/rules/extensions.md +278 -0
- package/configs/flask/.claude/rules/flask.md +171 -0
- package/configs/flask/.claude/rules/marshmallow.md +206 -0
- package/configs/flask/.claude/rules/security.md +267 -0
- package/configs/flask/.claude/rules/testing.md +284 -0
- package/configs/flask/.claude/settings.json +33 -0
- package/configs/flask/CLAUDE.md +166 -0
- package/configs/nestjs/.claude/rules/common-patterns.md +300 -0
- package/configs/nestjs/.claude/rules/filters.md +376 -0
- package/configs/nestjs/.claude/rules/interceptors.md +317 -0
- package/configs/nestjs/.claude/rules/middleware.md +321 -0
- package/configs/nestjs/.claude/rules/modules.md +26 -0
- package/configs/nestjs/.claude/rules/pipes.md +351 -0
- package/configs/nestjs/.claude/rules/websockets.md +451 -0
- package/configs/nestjs/.claude/settings.json +16 -2
- package/configs/nestjs/CLAUDE.md +57 -215
- package/configs/nextjs/.claude/rules/api-routes.md +358 -0
- package/configs/nextjs/.claude/rules/authentication.md +355 -0
- package/configs/nextjs/.claude/rules/components.md +52 -0
- package/configs/nextjs/.claude/rules/data-fetching.md +249 -0
- package/configs/nextjs/.claude/rules/database.md +400 -0
- package/configs/nextjs/.claude/rules/middleware.md +303 -0
- package/configs/nextjs/.claude/rules/routing.md +324 -0
- package/configs/nextjs/.claude/rules/seo.md +350 -0
- package/configs/nextjs/.claude/rules/server-actions.md +353 -0
- package/configs/nextjs/.claude/rules/state/zustand.md +6 -6
- package/configs/nextjs/.claude/settings.json +5 -0
- package/configs/nextjs/CLAUDE.md +69 -331
- package/package.json +23 -9
- package/src/cli.js +220 -0
- package/src/config.js +29 -0
- package/src/index.js +13 -0
- package/src/installer.js +361 -0
- package/src/merge.js +116 -0
- package/src/tech-config.json +29 -0
- package/src/utils.js +96 -0
- package/configs/python/.claude/rules/flask.md +0 -332
- package/configs/python/.claude/settings.json +0 -18
- package/configs/python/CLAUDE.md +0 -273
- package/src/install.js +0 -315
- /package/configs/_shared/.claude/rules/{accessibility.md → domain/frontend/accessibility.md} +0 -0
- /package/configs/_shared/.claude/rules/{security.md → security/security.md} +0 -0
- /package/configs/_shared/.claude/skills/{debug → dev/debug}/SKILL.md +0 -0
- /package/configs/_shared/.claude/skills/{learning → dev/learning}/SKILL.md +0 -0
- /package/configs/_shared/.claude/skills/{spec → dev/spec}/SKILL.md +0 -0
- /package/configs/_shared/.claude/skills/{review → git/review}/SKILL.md +0 -0
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
---
|
|
2
|
+
paths:
|
|
3
|
+
- "**/*.py"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# FastAPI Response Patterns
|
|
7
|
+
|
|
8
|
+
## Response Models
|
|
9
|
+
|
|
10
|
+
```python
|
|
11
|
+
from pydantic import BaseModel, ConfigDict
|
|
12
|
+
|
|
13
|
+
# GOOD - explicit response model
|
|
14
|
+
class UserResponse(BaseModel):
|
|
15
|
+
id: int
|
|
16
|
+
email: str
|
|
17
|
+
name: str
|
|
18
|
+
created_at: datetime
|
|
19
|
+
|
|
20
|
+
model_config = ConfigDict(from_attributes=True)
|
|
21
|
+
|
|
22
|
+
@router.get("/users/{user_id}", response_model=UserResponse)
|
|
23
|
+
async def get_user(user_id: int, db: DbSession) -> User:
|
|
24
|
+
user = await db.get(User, user_id)
|
|
25
|
+
if not user:
|
|
26
|
+
raise HTTPException(404, "User not found")
|
|
27
|
+
return user # Automatically serialized to UserResponse
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Pagination Response
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
from typing import Generic, TypeVar
|
|
34
|
+
|
|
35
|
+
T = TypeVar("T")
|
|
36
|
+
|
|
37
|
+
class Page(BaseModel, Generic[T]):
|
|
38
|
+
items: list[T]
|
|
39
|
+
total: int
|
|
40
|
+
page: int
|
|
41
|
+
size: int
|
|
42
|
+
pages: int
|
|
43
|
+
|
|
44
|
+
@classmethod
|
|
45
|
+
def create(cls, items: list[T], total: int, page: int, size: int) -> "Page[T]":
|
|
46
|
+
return cls(
|
|
47
|
+
items=items,
|
|
48
|
+
total=total,
|
|
49
|
+
page=page,
|
|
50
|
+
size=size,
|
|
51
|
+
pages=(total + size - 1) // size,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
@router.get("/users", response_model=Page[UserResponse])
|
|
55
|
+
async def list_users(
|
|
56
|
+
page: int = Query(1, ge=1),
|
|
57
|
+
size: int = Query(20, ge=1, le=100),
|
|
58
|
+
db: DbSession,
|
|
59
|
+
) -> Page[User]:
|
|
60
|
+
total = await db.scalar(select(func.count()).select_from(User))
|
|
61
|
+
|
|
62
|
+
users = await db.scalars(
|
|
63
|
+
select(User)
|
|
64
|
+
.offset((page - 1) * size)
|
|
65
|
+
.limit(size)
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
return Page.create(list(users), total, page, size)
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Response Classes
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
from fastapi.responses import (
|
|
75
|
+
JSONResponse,
|
|
76
|
+
HTMLResponse,
|
|
77
|
+
PlainTextResponse,
|
|
78
|
+
RedirectResponse,
|
|
79
|
+
StreamingResponse,
|
|
80
|
+
FileResponse,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
# JSON with custom status and headers
|
|
84
|
+
@router.post("/users")
|
|
85
|
+
async def create_user(data: UserCreate) -> JSONResponse:
|
|
86
|
+
user = await user_service.create(data)
|
|
87
|
+
return JSONResponse(
|
|
88
|
+
content={"id": user.id, "message": "User created"},
|
|
89
|
+
status_code=201,
|
|
90
|
+
headers={"X-Custom-Header": "value"},
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
# HTML response
|
|
94
|
+
@router.get("/page", response_class=HTMLResponse)
|
|
95
|
+
async def get_page() -> str:
|
|
96
|
+
return "<html><body><h1>Hello</h1></body></html>"
|
|
97
|
+
|
|
98
|
+
# Redirect
|
|
99
|
+
@router.get("/old-path")
|
|
100
|
+
async def redirect() -> RedirectResponse:
|
|
101
|
+
return RedirectResponse(url="/new-path", status_code=301)
|
|
102
|
+
|
|
103
|
+
# File download
|
|
104
|
+
@router.get("/download/{filename}")
|
|
105
|
+
async def download_file(filename: str) -> FileResponse:
|
|
106
|
+
return FileResponse(
|
|
107
|
+
path=f"files/{filename}",
|
|
108
|
+
filename=filename,
|
|
109
|
+
media_type="application/octet-stream",
|
|
110
|
+
)
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## Streaming Response
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
import asyncio
|
|
117
|
+
from typing import AsyncGenerator
|
|
118
|
+
|
|
119
|
+
async def generate_data() -> AsyncGenerator[bytes, None]:
|
|
120
|
+
for i in range(100):
|
|
121
|
+
yield f"data: {i}\n\n".encode()
|
|
122
|
+
await asyncio.sleep(0.1)
|
|
123
|
+
|
|
124
|
+
@router.get("/stream")
|
|
125
|
+
async def stream_data() -> StreamingResponse:
|
|
126
|
+
return StreamingResponse(
|
|
127
|
+
generate_data(),
|
|
128
|
+
media_type="text/event-stream",
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
# Stream large file
|
|
132
|
+
@router.get("/large-file")
|
|
133
|
+
async def stream_file() -> StreamingResponse:
|
|
134
|
+
async def iterfile():
|
|
135
|
+
async with aiofiles.open("large_file.csv", "rb") as f:
|
|
136
|
+
while chunk := await f.read(8192):
|
|
137
|
+
yield chunk
|
|
138
|
+
|
|
139
|
+
return StreamingResponse(
|
|
140
|
+
iterfile(),
|
|
141
|
+
media_type="text/csv",
|
|
142
|
+
headers={"Content-Disposition": "attachment; filename=data.csv"},
|
|
143
|
+
)
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
## Error Responses
|
|
147
|
+
|
|
148
|
+
```python
|
|
149
|
+
from fastapi import HTTPException
|
|
150
|
+
from fastapi.responses import JSONResponse
|
|
151
|
+
|
|
152
|
+
# Standard HTTP exception
|
|
153
|
+
@router.get("/users/{user_id}")
|
|
154
|
+
async def get_user(user_id: int) -> UserResponse:
|
|
155
|
+
user = await db.get(User, user_id)
|
|
156
|
+
if not user:
|
|
157
|
+
raise HTTPException(
|
|
158
|
+
status_code=404,
|
|
159
|
+
detail="User not found",
|
|
160
|
+
headers={"X-Error-Code": "USER_NOT_FOUND"},
|
|
161
|
+
)
|
|
162
|
+
return user
|
|
163
|
+
|
|
164
|
+
# Custom error response model
|
|
165
|
+
class ErrorResponse(BaseModel):
|
|
166
|
+
error: str
|
|
167
|
+
detail: str
|
|
168
|
+
request_id: str | None = None
|
|
169
|
+
|
|
170
|
+
@router.get(
|
|
171
|
+
"/users/{user_id}",
|
|
172
|
+
responses={
|
|
173
|
+
200: {"model": UserResponse},
|
|
174
|
+
404: {"model": ErrorResponse, "description": "User not found"},
|
|
175
|
+
500: {"model": ErrorResponse, "description": "Internal server error"},
|
|
176
|
+
},
|
|
177
|
+
)
|
|
178
|
+
async def get_user(user_id: int) -> UserResponse:
|
|
179
|
+
...
|
|
180
|
+
|
|
181
|
+
# Global exception handler
|
|
182
|
+
@app.exception_handler(ValueError)
|
|
183
|
+
async def value_error_handler(request: Request, exc: ValueError) -> JSONResponse:
|
|
184
|
+
return JSONResponse(
|
|
185
|
+
status_code=400,
|
|
186
|
+
content={"error": "Bad Request", "detail": str(exc)},
|
|
187
|
+
)
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
## Response Headers
|
|
191
|
+
|
|
192
|
+
```python
|
|
193
|
+
from fastapi import Response
|
|
194
|
+
|
|
195
|
+
@router.get("/data")
|
|
196
|
+
async def get_data(response: Response) -> dict:
|
|
197
|
+
response.headers["X-Custom-Header"] = "custom-value"
|
|
198
|
+
response.headers["Cache-Control"] = "max-age=3600"
|
|
199
|
+
return {"data": "value"}
|
|
200
|
+
|
|
201
|
+
# Set cookies
|
|
202
|
+
@router.post("/login")
|
|
203
|
+
async def login(response: Response) -> dict:
|
|
204
|
+
response.set_cookie(
|
|
205
|
+
key="session_id",
|
|
206
|
+
value="abc123",
|
|
207
|
+
httponly=True,
|
|
208
|
+
secure=True,
|
|
209
|
+
samesite="lax",
|
|
210
|
+
max_age=3600,
|
|
211
|
+
)
|
|
212
|
+
return {"status": "logged_in"}
|
|
213
|
+
|
|
214
|
+
# Delete cookie
|
|
215
|
+
@router.post("/logout")
|
|
216
|
+
async def logout(response: Response) -> dict:
|
|
217
|
+
response.delete_cookie("session_id")
|
|
218
|
+
return {"status": "logged_out"}
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
## Background Response
|
|
222
|
+
|
|
223
|
+
```python
|
|
224
|
+
from fastapi import BackgroundTasks
|
|
225
|
+
|
|
226
|
+
@router.post("/send-email")
|
|
227
|
+
async def send_email(
|
|
228
|
+
email: EmailSchema,
|
|
229
|
+
background_tasks: BackgroundTasks,
|
|
230
|
+
) -> dict:
|
|
231
|
+
# Return immediately
|
|
232
|
+
background_tasks.add_task(send_email_async, email.to, email.subject, email.body)
|
|
233
|
+
return {"message": "Email queued"}
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
## No Content Response
|
|
237
|
+
|
|
238
|
+
```python
|
|
239
|
+
from fastapi import Response, status
|
|
240
|
+
|
|
241
|
+
@router.delete("/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
242
|
+
async def delete_user(user_id: int, db: DbSession) -> Response:
|
|
243
|
+
user = await db.get(User, user_id)
|
|
244
|
+
if not user:
|
|
245
|
+
raise HTTPException(404, "User not found")
|
|
246
|
+
|
|
247
|
+
await db.delete(user)
|
|
248
|
+
await db.commit()
|
|
249
|
+
|
|
250
|
+
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
|
251
|
+
```
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
---
|
|
2
|
+
paths:
|
|
3
|
+
- "**/routers/**/*.py"
|
|
4
|
+
- "**/routes/**/*.py"
|
|
5
|
+
- "**/api/**/*.py"
|
|
6
|
+
- "**/endpoints/**/*.py"
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# FastAPI Router Patterns
|
|
10
|
+
|
|
11
|
+
## Router Organization
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
# GOOD - organized router structure
|
|
15
|
+
# app/users/router.py
|
|
16
|
+
from fastapi import APIRouter, status
|
|
17
|
+
|
|
18
|
+
router = APIRouter(
|
|
19
|
+
prefix="/users",
|
|
20
|
+
tags=["Users"],
|
|
21
|
+
responses={404: {"description": "User not found"}},
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
@router.get("/", response_model=list[UserResponse])
|
|
25
|
+
async def list_users(db: DbSession) -> list[User]:
|
|
26
|
+
"""List all active users."""
|
|
27
|
+
return await db.scalars(select(User).where(User.is_active))
|
|
28
|
+
|
|
29
|
+
@router.post("/", status_code=status.HTTP_201_CREATED)
|
|
30
|
+
async def create_user(data: UserCreate, db: DbSession) -> UserResponse:
|
|
31
|
+
"""Create a new user."""
|
|
32
|
+
user = User(**data.model_dump())
|
|
33
|
+
db.add(user)
|
|
34
|
+
await db.commit()
|
|
35
|
+
return user
|
|
36
|
+
|
|
37
|
+
@router.get("/{user_id}")
|
|
38
|
+
async def get_user(user_id: int, db: DbSession) -> UserResponse:
|
|
39
|
+
"""Get user by ID."""
|
|
40
|
+
user = await db.get(User, user_id)
|
|
41
|
+
if not user:
|
|
42
|
+
raise HTTPException(404, "User not found")
|
|
43
|
+
return user
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Router Registration
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
# app/main.py
|
|
50
|
+
from fastapi import FastAPI
|
|
51
|
+
from app.users.router import router as users_router
|
|
52
|
+
from app.auth.router import router as auth_router
|
|
53
|
+
from app.products.router import router as products_router
|
|
54
|
+
|
|
55
|
+
app = FastAPI(title="My API", version="1.0.0")
|
|
56
|
+
|
|
57
|
+
# Version prefix
|
|
58
|
+
api_v1 = APIRouter(prefix="/api/v1")
|
|
59
|
+
api_v1.include_router(users_router)
|
|
60
|
+
api_v1.include_router(auth_router)
|
|
61
|
+
api_v1.include_router(products_router)
|
|
62
|
+
|
|
63
|
+
app.include_router(api_v1)
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Path Parameters
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
# GOOD - typed path parameters with validation
|
|
70
|
+
@router.get("/{user_id}")
|
|
71
|
+
async def get_user(
|
|
72
|
+
user_id: Annotated[int, Path(ge=1, description="User ID")],
|
|
73
|
+
) -> UserResponse:
|
|
74
|
+
...
|
|
75
|
+
|
|
76
|
+
# UUID path parameter
|
|
77
|
+
@router.get("/{item_id}")
|
|
78
|
+
async def get_item(item_id: UUID) -> ItemResponse:
|
|
79
|
+
...
|
|
80
|
+
|
|
81
|
+
# Multiple path parameters
|
|
82
|
+
@router.get("/{user_id}/orders/{order_id}")
|
|
83
|
+
async def get_user_order(user_id: int, order_id: int) -> OrderResponse:
|
|
84
|
+
...
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## Query Parameters
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
# GOOD - query parameters with defaults and validation
|
|
91
|
+
@router.get("/")
|
|
92
|
+
async def list_items(
|
|
93
|
+
skip: int = Query(0, ge=0),
|
|
94
|
+
limit: int = Query(20, ge=1, le=100),
|
|
95
|
+
search: str | None = Query(None, min_length=1, max_length=100),
|
|
96
|
+
status: ItemStatus | None = None,
|
|
97
|
+
sort_by: Literal["name", "created_at", "price"] = "created_at",
|
|
98
|
+
order: Literal["asc", "desc"] = "desc",
|
|
99
|
+
) -> Page[ItemResponse]:
|
|
100
|
+
...
|
|
101
|
+
|
|
102
|
+
# List query parameter
|
|
103
|
+
@router.get("/")
|
|
104
|
+
async def get_items(
|
|
105
|
+
ids: Annotated[list[int], Query()] = [],
|
|
106
|
+
) -> list[ItemResponse]:
|
|
107
|
+
...
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## Request Body
|
|
111
|
+
|
|
112
|
+
```python
|
|
113
|
+
# GOOD - explicit body parameter
|
|
114
|
+
@router.post("/")
|
|
115
|
+
async def create_item(
|
|
116
|
+
item: Annotated[ItemCreate, Body(embed=True)],
|
|
117
|
+
) -> ItemResponse:
|
|
118
|
+
...
|
|
119
|
+
|
|
120
|
+
# Multiple body parameters
|
|
121
|
+
@router.post("/transfer")
|
|
122
|
+
async def transfer(
|
|
123
|
+
source: Annotated[Account, Body()],
|
|
124
|
+
destination: Annotated[Account, Body()],
|
|
125
|
+
amount: Annotated[Decimal, Body(gt=0)],
|
|
126
|
+
) -> TransferResult:
|
|
127
|
+
...
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## Response Configuration
|
|
131
|
+
|
|
132
|
+
```python
|
|
133
|
+
# GOOD - explicit response models and status codes
|
|
134
|
+
@router.post(
|
|
135
|
+
"/",
|
|
136
|
+
response_model=UserResponse,
|
|
137
|
+
status_code=status.HTTP_201_CREATED,
|
|
138
|
+
responses={
|
|
139
|
+
201: {"description": "User created successfully"},
|
|
140
|
+
400: {"model": ErrorResponse, "description": "Validation error"},
|
|
141
|
+
409: {"model": ErrorResponse, "description": "Email already exists"},
|
|
142
|
+
},
|
|
143
|
+
)
|
|
144
|
+
async def create_user(data: UserCreate) -> User:
|
|
145
|
+
...
|
|
146
|
+
|
|
147
|
+
# Exclude fields from response
|
|
148
|
+
@router.get("/", response_model_exclude={"password", "secret"})
|
|
149
|
+
async def get_user() -> User:
|
|
150
|
+
...
|
|
151
|
+
|
|
152
|
+
# Dynamic response model
|
|
153
|
+
@router.get("/", response_model=UserResponse | AdminResponse)
|
|
154
|
+
async def get_current(user: CurrentUser) -> User:
|
|
155
|
+
return user
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
## File Uploads
|
|
159
|
+
|
|
160
|
+
```python
|
|
161
|
+
from fastapi import File, UploadFile
|
|
162
|
+
|
|
163
|
+
@router.post("/upload")
|
|
164
|
+
async def upload_file(
|
|
165
|
+
file: Annotated[UploadFile, File(description="File to upload")],
|
|
166
|
+
) -> dict:
|
|
167
|
+
contents = await file.read()
|
|
168
|
+
return {"filename": file.filename, "size": len(contents)}
|
|
169
|
+
|
|
170
|
+
# Multiple files
|
|
171
|
+
@router.post("/upload-many")
|
|
172
|
+
async def upload_files(
|
|
173
|
+
files: Annotated[list[UploadFile], File()],
|
|
174
|
+
) -> list[dict]:
|
|
175
|
+
return [{"filename": f.filename} for f in files]
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
## Form Data
|
|
179
|
+
|
|
180
|
+
```python
|
|
181
|
+
from fastapi import Form
|
|
182
|
+
|
|
183
|
+
@router.post("/login")
|
|
184
|
+
async def login(
|
|
185
|
+
username: Annotated[str, Form()],
|
|
186
|
+
password: Annotated[str, Form()],
|
|
187
|
+
) -> Token:
|
|
188
|
+
...
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
## Headers and Cookies
|
|
192
|
+
|
|
193
|
+
```python
|
|
194
|
+
from fastapi import Header, Cookie
|
|
195
|
+
|
|
196
|
+
@router.get("/")
|
|
197
|
+
async def get_items(
|
|
198
|
+
x_token: Annotated[str, Header()],
|
|
199
|
+
session_id: Annotated[str | None, Cookie()] = None,
|
|
200
|
+
) -> list[Item]:
|
|
201
|
+
...
|
|
202
|
+
```
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
---
|
|
2
|
+
paths:
|
|
3
|
+
- "**/*.py"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# FastAPI Security Patterns
|
|
7
|
+
|
|
8
|
+
## OAuth2 Password Flow
|
|
9
|
+
|
|
10
|
+
```python
|
|
11
|
+
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
|
|
12
|
+
|
|
13
|
+
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/token")
|
|
14
|
+
|
|
15
|
+
@router.post("/token")
|
|
16
|
+
async def login(
|
|
17
|
+
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
|
|
18
|
+
db: DbSession,
|
|
19
|
+
) -> Token:
|
|
20
|
+
user = await authenticate_user(db, form_data.username, form_data.password)
|
|
21
|
+
if not user:
|
|
22
|
+
raise HTTPException(
|
|
23
|
+
status_code=401,
|
|
24
|
+
detail="Incorrect username or password",
|
|
25
|
+
headers={"WWW-Authenticate": "Bearer"},
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
access_token = create_access_token(data={"sub": str(user.id)})
|
|
29
|
+
return Token(access_token=access_token, token_type="bearer")
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## JWT Authentication
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
from jose import JWTError, jwt
|
|
36
|
+
from datetime import datetime, timedelta
|
|
37
|
+
|
|
38
|
+
SECRET_KEY = settings.secret_key
|
|
39
|
+
ALGORITHM = "HS256"
|
|
40
|
+
ACCESS_TOKEN_EXPIRE_MINUTES = 30
|
|
41
|
+
|
|
42
|
+
def create_access_token(data: dict) -> str:
|
|
43
|
+
to_encode = data.copy()
|
|
44
|
+
expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
|
45
|
+
to_encode.update({"exp": expire})
|
|
46
|
+
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
|
47
|
+
|
|
48
|
+
async def get_current_user(
|
|
49
|
+
token: Annotated[str, Depends(oauth2_scheme)],
|
|
50
|
+
db: DbSession,
|
|
51
|
+
) -> User:
|
|
52
|
+
credentials_exception = HTTPException(
|
|
53
|
+
status_code=401,
|
|
54
|
+
detail="Could not validate credentials",
|
|
55
|
+
headers={"WWW-Authenticate": "Bearer"},
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
try:
|
|
59
|
+
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
|
60
|
+
user_id: str = payload.get("sub")
|
|
61
|
+
if user_id is None:
|
|
62
|
+
raise credentials_exception
|
|
63
|
+
except JWTError:
|
|
64
|
+
raise credentials_exception
|
|
65
|
+
|
|
66
|
+
user = await db.get(User, int(user_id))
|
|
67
|
+
if user is None:
|
|
68
|
+
raise credentials_exception
|
|
69
|
+
|
|
70
|
+
return user
|
|
71
|
+
|
|
72
|
+
CurrentUser = Annotated[User, Depends(get_current_user)]
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## API Key Authentication
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
from fastapi.security import APIKeyHeader, APIKeyQuery
|
|
79
|
+
|
|
80
|
+
api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
|
|
81
|
+
api_key_query = APIKeyQuery(name="api_key", auto_error=False)
|
|
82
|
+
|
|
83
|
+
async def get_api_key(
|
|
84
|
+
api_key_header: str | None = Depends(api_key_header),
|
|
85
|
+
api_key_query: str | None = Depends(api_key_query),
|
|
86
|
+
) -> str:
|
|
87
|
+
api_key = api_key_header or api_key_query
|
|
88
|
+
if not api_key:
|
|
89
|
+
raise HTTPException(403, "API key required")
|
|
90
|
+
|
|
91
|
+
if not await verify_api_key(api_key):
|
|
92
|
+
raise HTTPException(403, "Invalid API key")
|
|
93
|
+
|
|
94
|
+
return api_key
|
|
95
|
+
|
|
96
|
+
@router.get("/data", dependencies=[Depends(get_api_key)])
|
|
97
|
+
async def get_data() -> dict:
|
|
98
|
+
...
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## Role-Based Access Control
|
|
102
|
+
|
|
103
|
+
```python
|
|
104
|
+
from enum import Enum
|
|
105
|
+
from functools import wraps
|
|
106
|
+
|
|
107
|
+
class Role(str, Enum):
|
|
108
|
+
USER = "user"
|
|
109
|
+
ADMIN = "admin"
|
|
110
|
+
MODERATOR = "moderator"
|
|
111
|
+
|
|
112
|
+
def require_roles(*roles: Role):
|
|
113
|
+
async def dependency(user: CurrentUser) -> User:
|
|
114
|
+
if user.role not in roles:
|
|
115
|
+
raise HTTPException(
|
|
116
|
+
status_code=403,
|
|
117
|
+
detail=f"Required roles: {', '.join(r.value for r in roles)}",
|
|
118
|
+
)
|
|
119
|
+
return user
|
|
120
|
+
return dependency
|
|
121
|
+
|
|
122
|
+
AdminOnly = Annotated[User, Depends(require_roles(Role.ADMIN))]
|
|
123
|
+
ModeratorOrAdmin = Annotated[User, Depends(require_roles(Role.ADMIN, Role.MODERATOR))]
|
|
124
|
+
|
|
125
|
+
@router.delete("/{user_id}")
|
|
126
|
+
async def delete_user(user_id: int, admin: AdminOnly) -> None:
|
|
127
|
+
...
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## Permission-Based Access
|
|
131
|
+
|
|
132
|
+
```python
|
|
133
|
+
class Permission(str, Enum):
|
|
134
|
+
READ_USERS = "read:users"
|
|
135
|
+
WRITE_USERS = "write:users"
|
|
136
|
+
DELETE_USERS = "delete:users"
|
|
137
|
+
|
|
138
|
+
def require_permissions(*permissions: Permission):
|
|
139
|
+
async def dependency(user: CurrentUser) -> User:
|
|
140
|
+
user_permissions = set(user.permissions)
|
|
141
|
+
required = set(permissions)
|
|
142
|
+
|
|
143
|
+
if not required.issubset(user_permissions):
|
|
144
|
+
missing = required - user_permissions
|
|
145
|
+
raise HTTPException(
|
|
146
|
+
status_code=403,
|
|
147
|
+
detail=f"Missing permissions: {', '.join(p.value for p in missing)}",
|
|
148
|
+
)
|
|
149
|
+
return user
|
|
150
|
+
return dependency
|
|
151
|
+
|
|
152
|
+
CanDeleteUsers = Annotated[User, Depends(require_permissions(Permission.DELETE_USERS))]
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
## Password Hashing
|
|
156
|
+
|
|
157
|
+
```python
|
|
158
|
+
from passlib.context import CryptContext
|
|
159
|
+
|
|
160
|
+
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
|
161
|
+
|
|
162
|
+
def hash_password(password: str) -> str:
|
|
163
|
+
return pwd_context.hash(password)
|
|
164
|
+
|
|
165
|
+
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
|
166
|
+
return pwd_context.verify(plain_password, hashed_password)
|
|
167
|
+
|
|
168
|
+
async def authenticate_user(db: DbSession, email: str, password: str) -> User | None:
|
|
169
|
+
user = await db.scalar(select(User).where(User.email == email))
|
|
170
|
+
if not user:
|
|
171
|
+
return None
|
|
172
|
+
if not verify_password(password, user.hashed_password):
|
|
173
|
+
return None
|
|
174
|
+
return user
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
## CORS Configuration
|
|
178
|
+
|
|
179
|
+
```python
|
|
180
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
181
|
+
|
|
182
|
+
app.add_middleware(
|
|
183
|
+
CORSMiddleware,
|
|
184
|
+
allow_origins=settings.cors_origins, # ["https://example.com"]
|
|
185
|
+
allow_credentials=True,
|
|
186
|
+
allow_methods=["GET", "POST", "PUT", "DELETE"],
|
|
187
|
+
allow_headers=["*"],
|
|
188
|
+
expose_headers=["X-Request-ID"],
|
|
189
|
+
)
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
## Security Headers Middleware
|
|
193
|
+
|
|
194
|
+
```python
|
|
195
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
196
|
+
|
|
197
|
+
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
|
198
|
+
async def dispatch(self, request: Request, call_next):
|
|
199
|
+
response = await call_next(request)
|
|
200
|
+
response.headers["X-Content-Type-Options"] = "nosniff"
|
|
201
|
+
response.headers["X-Frame-Options"] = "DENY"
|
|
202
|
+
response.headers["X-XSS-Protection"] = "1; mode=block"
|
|
203
|
+
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
|
|
204
|
+
return response
|
|
205
|
+
|
|
206
|
+
app.add_middleware(SecurityHeadersMiddleware)
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
## Rate Limiting
|
|
210
|
+
|
|
211
|
+
```python
|
|
212
|
+
from slowapi import Limiter
|
|
213
|
+
from slowapi.util import get_remote_address
|
|
214
|
+
|
|
215
|
+
limiter = Limiter(key_func=get_remote_address)
|
|
216
|
+
app.state.limiter = limiter
|
|
217
|
+
|
|
218
|
+
@router.get("/")
|
|
219
|
+
@limiter.limit("10/minute")
|
|
220
|
+
async def get_items(request: Request) -> list[Item]:
|
|
221
|
+
...
|
|
222
|
+
```
|