@malamute/ai-rules 1.0.0 → 1.3.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 +272 -121
- package/bin/cli.js +5 -2
- package/configs/_shared/CLAUDE.md +52 -149
- package/configs/_shared/rules/conventions/documentation.md +324 -0
- package/configs/_shared/rules/conventions/git.md +265 -0
- package/configs/_shared/rules/conventions/npm.md +80 -0
- package/configs/_shared/{.claude/rules → rules/conventions}/performance.md +1 -1
- package/configs/_shared/rules/conventions/principles.md +334 -0
- package/configs/_shared/rules/devops/ci-cd.md +262 -0
- package/configs/_shared/rules/devops/docker.md +275 -0
- package/configs/_shared/rules/devops/nx.md +194 -0
- package/configs/_shared/rules/domain/backend/api-design.md +203 -0
- package/configs/_shared/rules/lang/csharp/async.md +220 -0
- package/configs/_shared/rules/lang/csharp/csharp.md +314 -0
- package/configs/_shared/rules/lang/csharp/linq.md +210 -0
- package/configs/_shared/rules/lang/python/async.md +337 -0
- package/configs/_shared/rules/lang/python/celery.md +476 -0
- package/configs/_shared/rules/lang/python/config.md +339 -0
- package/configs/{python/.claude/rules → _shared/rules/lang/python}/database/sqlalchemy.md +6 -1
- package/configs/_shared/rules/lang/python/deployment.md +523 -0
- package/configs/_shared/rules/lang/python/error-handling.md +330 -0
- package/configs/_shared/rules/lang/python/migrations.md +421 -0
- package/configs/_shared/rules/lang/python/python.md +172 -0
- package/configs/_shared/rules/lang/python/repository.md +383 -0
- package/configs/{python/.claude/rules → _shared/rules/lang/python}/testing.md +2 -69
- package/configs/_shared/rules/lang/typescript/async.md +447 -0
- package/configs/_shared/rules/lang/typescript/generics.md +356 -0
- package/configs/_shared/rules/lang/typescript/typescript.md +212 -0
- package/configs/_shared/rules/quality/error-handling.md +48 -0
- package/configs/_shared/rules/quality/logging.md +45 -0
- package/configs/_shared/rules/quality/observability.md +240 -0
- package/configs/_shared/rules/quality/testing-patterns.md +65 -0
- package/configs/_shared/rules/security/secrets-management.md +222 -0
- package/configs/_shared/skills/analysis/explore/SKILL.md +257 -0
- package/configs/_shared/skills/analysis/security-audit/SKILL.md +184 -0
- package/configs/_shared/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/skills/infra/deploy/SKILL.md +139 -0
- package/configs/_shared/skills/infra/docker/SKILL.md +95 -0
- package/configs/_shared/skills/infra/migration/SKILL.md +158 -0
- package/configs/_shared/skills/nx/nx-affected/SKILL.md +72 -0
- package/configs/_shared/skills/nx/nx-lib/SKILL.md +375 -0
- package/configs/angular/CLAUDE.md +24 -216
- package/configs/angular/{.claude/rules → rules/core}/components.md +69 -15
- package/configs/angular/rules/core/resource.md +285 -0
- package/configs/angular/rules/core/signals.md +323 -0
- package/configs/angular/rules/http.md +338 -0
- package/configs/angular/rules/routing.md +291 -0
- package/configs/angular/rules/ssr.md +312 -0
- package/configs/angular/rules/state/signal-store.md +408 -0
- package/configs/angular/{.claude/rules → rules/state}/state.md +2 -2
- package/configs/angular/{.claude/rules → rules}/testing.md +7 -7
- package/configs/angular/rules/ui/aria.md +422 -0
- package/configs/angular/rules/ui/forms.md +424 -0
- package/configs/angular/rules/ui/pipes-directives.md +335 -0
- package/configs/angular/{.claude/settings.json → settings.json} +3 -0
- package/configs/dotnet/CLAUDE.md +53 -286
- package/configs/dotnet/rules/background-services.md +552 -0
- package/configs/dotnet/rules/configuration.md +426 -0
- package/configs/dotnet/rules/ddd.md +447 -0
- package/configs/dotnet/rules/dependency-injection.md +343 -0
- package/configs/dotnet/rules/mediatr.md +320 -0
- package/configs/dotnet/rules/middleware.md +489 -0
- package/configs/dotnet/rules/result-pattern.md +363 -0
- package/configs/dotnet/rules/validation.md +388 -0
- package/configs/dotnet/settings.json +29 -0
- package/configs/fastapi/CLAUDE.md +144 -0
- package/configs/fastapi/rules/background-tasks.md +254 -0
- package/configs/fastapi/rules/dependencies.md +170 -0
- package/configs/{python/.claude → fastapi}/rules/fastapi.md +61 -1
- package/configs/fastapi/rules/lifespan.md +274 -0
- package/configs/fastapi/rules/middleware.md +229 -0
- package/configs/fastapi/rules/pydantic.md +433 -0
- package/configs/fastapi/rules/responses.md +251 -0
- package/configs/fastapi/rules/routers.md +202 -0
- package/configs/fastapi/rules/security.md +222 -0
- package/configs/fastapi/rules/testing.md +251 -0
- package/configs/fastapi/rules/websockets.md +298 -0
- package/configs/fastapi/settings.json +35 -0
- package/configs/flask/CLAUDE.md +166 -0
- package/configs/flask/rules/blueprints.md +208 -0
- package/configs/flask/rules/cli.md +285 -0
- package/configs/flask/rules/configuration.md +281 -0
- package/configs/flask/rules/context.md +238 -0
- package/configs/flask/rules/error-handlers.md +278 -0
- package/configs/flask/rules/extensions.md +278 -0
- package/configs/flask/rules/flask.md +171 -0
- package/configs/flask/rules/marshmallow.md +206 -0
- package/configs/flask/rules/security.md +267 -0
- package/configs/flask/rules/testing.md +284 -0
- package/configs/flask/settings.json +35 -0
- package/configs/nestjs/CLAUDE.md +57 -215
- package/configs/nestjs/rules/common-patterns.md +300 -0
- package/configs/nestjs/rules/filters.md +376 -0
- package/configs/nestjs/rules/interceptors.md +317 -0
- package/configs/nestjs/rules/middleware.md +321 -0
- package/configs/nestjs/{.claude/rules → rules}/modules.md +26 -0
- package/configs/nestjs/rules/pipes.md +351 -0
- package/configs/nestjs/rules/websockets.md +451 -0
- package/configs/nestjs/settings.json +31 -0
- package/configs/nextjs/CLAUDE.md +69 -331
- package/configs/nextjs/rules/api-routes.md +358 -0
- package/configs/nextjs/rules/authentication.md +355 -0
- package/configs/nextjs/{.claude/rules → rules}/components.md +52 -0
- package/configs/nextjs/rules/data-fetching.md +249 -0
- package/configs/nextjs/rules/database.md +400 -0
- package/configs/nextjs/rules/middleware.md +303 -0
- package/configs/nextjs/rules/routing.md +324 -0
- package/configs/nextjs/rules/seo.md +350 -0
- package/configs/nextjs/rules/server-actions.md +353 -0
- package/configs/nextjs/{.claude/rules → rules}/state/zustand.md +6 -6
- package/configs/nextjs/{.claude/settings.json → settings.json} +7 -0
- package/package.json +24 -9
- package/src/cli.js +218 -0
- package/src/config.js +63 -0
- package/src/index.js +4 -0
- package/src/installer.js +414 -0
- package/src/merge.js +109 -0
- package/src/tech-config.json +45 -0
- package/src/utils.js +88 -0
- package/configs/dotnet/.claude/settings.json +0 -9
- package/configs/nestjs/.claude/settings.json +0 -15
- 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 → rules/domain/frontend}/accessibility.md +0 -0
- /package/configs/_shared/{.claude/rules → rules/security}/security.md +0 -0
- /package/configs/_shared/{.claude/skills → skills/dev}/debug/SKILL.md +0 -0
- /package/configs/_shared/{.claude/skills → skills/dev}/learning/SKILL.md +0 -0
- /package/configs/_shared/{.claude/skills → skills/dev}/spec/SKILL.md +0 -0
- /package/configs/_shared/{.claude/skills → skills/git}/review/SKILL.md +0 -0
- /package/configs/dotnet/{.claude/rules → rules}/api.md +0 -0
- /package/configs/dotnet/{.claude/rules → rules}/architecture.md +0 -0
- /package/configs/dotnet/{.claude/rules → rules}/database/efcore.md +0 -0
- /package/configs/dotnet/{.claude/rules → rules}/testing.md +0 -0
- /package/configs/nestjs/{.claude/rules → rules}/auth.md +0 -0
- /package/configs/nestjs/{.claude/rules → rules}/database/prisma.md +0 -0
- /package/configs/nestjs/{.claude/rules → rules}/database/typeorm.md +0 -0
- /package/configs/nestjs/{.claude/rules → rules}/testing.md +0 -0
- /package/configs/nestjs/{.claude/rules → rules}/validation.md +0 -0
- /package/configs/nextjs/{.claude/rules → rules}/state/redux-toolkit.md +0 -0
- /package/configs/nextjs/{.claude/rules → rules}/testing.md +0 -0
|
@@ -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
|
+
```
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
---
|
|
2
|
+
paths:
|
|
3
|
+
- "tests/**/*.py"
|
|
4
|
+
- "**/test_*.py"
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# FastAPI Testing Patterns
|
|
8
|
+
|
|
9
|
+
## Test Client Setup
|
|
10
|
+
|
|
11
|
+
```python
|
|
12
|
+
import pytest
|
|
13
|
+
from httpx import AsyncClient, ASGITransport
|
|
14
|
+
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
|
|
15
|
+
from sqlalchemy.orm import sessionmaker
|
|
16
|
+
|
|
17
|
+
from app.main import app
|
|
18
|
+
from app.database import Base, get_db
|
|
19
|
+
|
|
20
|
+
TEST_DATABASE_URL = "sqlite+aiosqlite:///./test.db"
|
|
21
|
+
|
|
22
|
+
@pytest.fixture(scope="session")
|
|
23
|
+
def event_loop():
|
|
24
|
+
import asyncio
|
|
25
|
+
loop = asyncio.new_event_loop()
|
|
26
|
+
yield loop
|
|
27
|
+
loop.close()
|
|
28
|
+
|
|
29
|
+
@pytest.fixture(scope="session")
|
|
30
|
+
async def engine():
|
|
31
|
+
engine = create_async_engine(TEST_DATABASE_URL)
|
|
32
|
+
async with engine.begin() as conn:
|
|
33
|
+
await conn.run_sync(Base.metadata.create_all)
|
|
34
|
+
yield engine
|
|
35
|
+
async with engine.begin() as conn:
|
|
36
|
+
await conn.run_sync(Base.metadata.drop_all)
|
|
37
|
+
await engine.dispose()
|
|
38
|
+
|
|
39
|
+
@pytest.fixture
|
|
40
|
+
async def db_session(engine):
|
|
41
|
+
async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
|
42
|
+
async with async_session() as session:
|
|
43
|
+
yield session
|
|
44
|
+
await session.rollback()
|
|
45
|
+
|
|
46
|
+
@pytest.fixture
|
|
47
|
+
async def client(db_session):
|
|
48
|
+
async def override_get_db():
|
|
49
|
+
yield db_session
|
|
50
|
+
|
|
51
|
+
app.dependency_overrides[get_db] = override_get_db
|
|
52
|
+
|
|
53
|
+
async with AsyncClient(
|
|
54
|
+
transport=ASGITransport(app=app),
|
|
55
|
+
base_url="http://test",
|
|
56
|
+
) as client:
|
|
57
|
+
yield client
|
|
58
|
+
|
|
59
|
+
app.dependency_overrides.clear()
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## API Tests
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
class TestUsersAPI:
|
|
66
|
+
async def test_create_user(self, client: AsyncClient):
|
|
67
|
+
response = await client.post("/api/v1/users", json={
|
|
68
|
+
"email": "test@example.com",
|
|
69
|
+
"password": "password123",
|
|
70
|
+
"name": "Test User",
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
assert response.status_code == 201
|
|
74
|
+
data = response.json()
|
|
75
|
+
assert data["email"] == "test@example.com"
|
|
76
|
+
assert "id" in data
|
|
77
|
+
assert "password" not in data
|
|
78
|
+
|
|
79
|
+
async def test_create_user_duplicate_email(self, client: AsyncClient, db_session):
|
|
80
|
+
# Create existing user
|
|
81
|
+
user = User(email="existing@example.com", name="Existing")
|
|
82
|
+
db_session.add(user)
|
|
83
|
+
await db_session.commit()
|
|
84
|
+
|
|
85
|
+
response = await client.post("/api/v1/users", json={
|
|
86
|
+
"email": "existing@example.com",
|
|
87
|
+
"password": "password123",
|
|
88
|
+
"name": "New User",
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
assert response.status_code == 409
|
|
92
|
+
assert "already exists" in response.json()["detail"]
|
|
93
|
+
|
|
94
|
+
async def test_get_user_not_found(self, client: AsyncClient):
|
|
95
|
+
response = await client.get("/api/v1/users/99999")
|
|
96
|
+
|
|
97
|
+
assert response.status_code == 404
|
|
98
|
+
|
|
99
|
+
async def test_list_users_pagination(self, client: AsyncClient, db_session):
|
|
100
|
+
# Create users
|
|
101
|
+
for i in range(25):
|
|
102
|
+
db_session.add(User(email=f"user{i}@example.com", name=f"User {i}"))
|
|
103
|
+
await db_session.commit()
|
|
104
|
+
|
|
105
|
+
response = await client.get("/api/v1/users?page=2&size=10")
|
|
106
|
+
|
|
107
|
+
assert response.status_code == 200
|
|
108
|
+
data = response.json()
|
|
109
|
+
assert len(data["items"]) == 10
|
|
110
|
+
assert data["total"] == 25
|
|
111
|
+
assert data["page"] == 2
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## Authentication Tests
|
|
115
|
+
|
|
116
|
+
```python
|
|
117
|
+
@pytest.fixture
|
|
118
|
+
async def auth_headers(client: AsyncClient, db_session):
|
|
119
|
+
# Create user
|
|
120
|
+
user = User(email="auth@example.com", name="Auth User")
|
|
121
|
+
user.set_password("password123")
|
|
122
|
+
db_session.add(user)
|
|
123
|
+
await db_session.commit()
|
|
124
|
+
|
|
125
|
+
# Get token
|
|
126
|
+
response = await client.post("/api/v1/auth/login", data={
|
|
127
|
+
"username": "auth@example.com",
|
|
128
|
+
"password": "password123",
|
|
129
|
+
})
|
|
130
|
+
token = response.json()["access_token"]
|
|
131
|
+
|
|
132
|
+
return {"Authorization": f"Bearer {token}"}
|
|
133
|
+
|
|
134
|
+
class TestAuthenticatedEndpoints:
|
|
135
|
+
async def test_get_me_unauthorized(self, client: AsyncClient):
|
|
136
|
+
response = await client.get("/api/v1/users/me")
|
|
137
|
+
assert response.status_code == 401
|
|
138
|
+
|
|
139
|
+
async def test_get_me_authorized(self, client: AsyncClient, auth_headers):
|
|
140
|
+
response = await client.get("/api/v1/users/me", headers=auth_headers)
|
|
141
|
+
assert response.status_code == 200
|
|
142
|
+
assert response.json()["email"] == "auth@example.com"
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
## Dependency Override
|
|
146
|
+
|
|
147
|
+
```python
|
|
148
|
+
from unittest.mock import AsyncMock
|
|
149
|
+
|
|
150
|
+
@pytest.fixture
|
|
151
|
+
def mock_email_service():
|
|
152
|
+
return AsyncMock()
|
|
153
|
+
|
|
154
|
+
async def test_signup_sends_email(client: AsyncClient, mock_email_service):
|
|
155
|
+
app.dependency_overrides[get_email_service] = lambda: mock_email_service
|
|
156
|
+
|
|
157
|
+
response = await client.post("/api/v1/auth/signup", json={
|
|
158
|
+
"email": "new@example.com",
|
|
159
|
+
"password": "password123",
|
|
160
|
+
"name": "New User",
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
assert response.status_code == 201
|
|
164
|
+
mock_email_service.send_welcome.assert_called_once_with("new@example.com")
|
|
165
|
+
|
|
166
|
+
app.dependency_overrides.clear()
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
## WebSocket Tests
|
|
170
|
+
|
|
171
|
+
```python
|
|
172
|
+
from httpx_ws import aconnect_ws
|
|
173
|
+
|
|
174
|
+
async def test_websocket_echo(client: AsyncClient):
|
|
175
|
+
async with aconnect_ws("http://test/ws", client) as ws:
|
|
176
|
+
await ws.send_text("Hello")
|
|
177
|
+
response = await ws.receive_text()
|
|
178
|
+
assert response == "Echo: Hello"
|
|
179
|
+
|
|
180
|
+
async def test_websocket_auth_required():
|
|
181
|
+
async with AsyncClient(
|
|
182
|
+
transport=ASGITransport(app=app),
|
|
183
|
+
base_url="http://test",
|
|
184
|
+
) as client:
|
|
185
|
+
with pytest.raises(Exception):
|
|
186
|
+
async with aconnect_ws("http://test/ws", client) as ws:
|
|
187
|
+
pass # Should fail without token
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
## File Upload Tests
|
|
191
|
+
|
|
192
|
+
```python
|
|
193
|
+
async def test_file_upload(client: AsyncClient, auth_headers):
|
|
194
|
+
files = {"file": ("test.txt", b"file content", "text/plain")}
|
|
195
|
+
|
|
196
|
+
response = await client.post(
|
|
197
|
+
"/api/v1/files/upload",
|
|
198
|
+
files=files,
|
|
199
|
+
headers=auth_headers,
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
assert response.status_code == 201
|
|
203
|
+
assert response.json()["filename"] == "test.txt"
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
## Parametrized Tests
|
|
207
|
+
|
|
208
|
+
```python
|
|
209
|
+
@pytest.mark.parametrize("email,expected_valid", [
|
|
210
|
+
("valid@example.com", True),
|
|
211
|
+
("also.valid@example.co.uk", True),
|
|
212
|
+
("invalid", False),
|
|
213
|
+
("missing@", False),
|
|
214
|
+
("@nodomain.com", False),
|
|
215
|
+
])
|
|
216
|
+
async def test_email_validation(client: AsyncClient, email: str, expected_valid: bool):
|
|
217
|
+
response = await client.post("/api/v1/users", json={
|
|
218
|
+
"email": email,
|
|
219
|
+
"password": "password123",
|
|
220
|
+
"name": "Test",
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
if expected_valid:
|
|
224
|
+
assert response.status_code in (201, 409) # Created or duplicate
|
|
225
|
+
else:
|
|
226
|
+
assert response.status_code == 422
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
## Test Markers
|
|
230
|
+
|
|
231
|
+
```python
|
|
232
|
+
# pyproject.toml
|
|
233
|
+
[tool.pytest.ini_options]
|
|
234
|
+
markers = [
|
|
235
|
+
"slow: marks tests as slow",
|
|
236
|
+
"integration: requires database",
|
|
237
|
+
]
|
|
238
|
+
|
|
239
|
+
# Usage
|
|
240
|
+
@pytest.mark.slow
|
|
241
|
+
async def test_heavy_computation():
|
|
242
|
+
...
|
|
243
|
+
|
|
244
|
+
@pytest.mark.integration
|
|
245
|
+
async def test_database_query():
|
|
246
|
+
...
|
|
247
|
+
|
|
248
|
+
# Run specific markers
|
|
249
|
+
# pytest -m "not slow"
|
|
250
|
+
# pytest -m integration
|
|
251
|
+
```
|