@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,229 @@
|
|
|
1
|
+
---
|
|
2
|
+
paths:
|
|
3
|
+
- "**/*.py"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# FastAPI Middleware Patterns
|
|
7
|
+
|
|
8
|
+
## Custom Middleware
|
|
9
|
+
|
|
10
|
+
```python
|
|
11
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
12
|
+
from starlette.requests import Request
|
|
13
|
+
from starlette.responses import Response
|
|
14
|
+
import time
|
|
15
|
+
|
|
16
|
+
class TimingMiddleware(BaseHTTPMiddleware):
|
|
17
|
+
async def dispatch(self, request: Request, call_next) -> Response:
|
|
18
|
+
start_time = time.perf_counter()
|
|
19
|
+
|
|
20
|
+
response = await call_next(request)
|
|
21
|
+
|
|
22
|
+
process_time = time.perf_counter() - start_time
|
|
23
|
+
response.headers["X-Process-Time"] = f"{process_time:.4f}"
|
|
24
|
+
|
|
25
|
+
return response
|
|
26
|
+
|
|
27
|
+
app.add_middleware(TimingMiddleware)
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Request ID Middleware
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
import uuid
|
|
34
|
+
from contextvars import ContextVar
|
|
35
|
+
|
|
36
|
+
request_id_ctx: ContextVar[str] = ContextVar("request_id", default="")
|
|
37
|
+
|
|
38
|
+
class RequestIDMiddleware(BaseHTTPMiddleware):
|
|
39
|
+
async def dispatch(self, request: Request, call_next) -> Response:
|
|
40
|
+
request_id = request.headers.get("X-Request-ID", str(uuid.uuid4()))
|
|
41
|
+
request_id_ctx.set(request_id)
|
|
42
|
+
|
|
43
|
+
response = await call_next(request)
|
|
44
|
+
response.headers["X-Request-ID"] = request_id
|
|
45
|
+
|
|
46
|
+
return response
|
|
47
|
+
|
|
48
|
+
def get_request_id() -> str:
|
|
49
|
+
return request_id_ctx.get()
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Logging Middleware
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
import logging
|
|
56
|
+
from typing import Callable
|
|
57
|
+
|
|
58
|
+
logger = logging.getLogger(__name__)
|
|
59
|
+
|
|
60
|
+
class LoggingMiddleware(BaseHTTPMiddleware):
|
|
61
|
+
async def dispatch(self, request: Request, call_next) -> Response:
|
|
62
|
+
# Log request
|
|
63
|
+
logger.info(
|
|
64
|
+
"Request started",
|
|
65
|
+
extra={
|
|
66
|
+
"method": request.method,
|
|
67
|
+
"path": request.url.path,
|
|
68
|
+
"client_ip": request.client.host,
|
|
69
|
+
"request_id": get_request_id(),
|
|
70
|
+
},
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
start_time = time.perf_counter()
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
response = await call_next(request)
|
|
77
|
+
except Exception as e:
|
|
78
|
+
logger.exception(
|
|
79
|
+
"Request failed",
|
|
80
|
+
extra={
|
|
81
|
+
"path": request.url.path,
|
|
82
|
+
"error": str(e),
|
|
83
|
+
"request_id": get_request_id(),
|
|
84
|
+
},
|
|
85
|
+
)
|
|
86
|
+
raise
|
|
87
|
+
|
|
88
|
+
duration = time.perf_counter() - start_time
|
|
89
|
+
|
|
90
|
+
# Log response
|
|
91
|
+
logger.info(
|
|
92
|
+
"Request completed",
|
|
93
|
+
extra={
|
|
94
|
+
"method": request.method,
|
|
95
|
+
"path": request.url.path,
|
|
96
|
+
"status_code": response.status_code,
|
|
97
|
+
"duration": f"{duration:.4f}s",
|
|
98
|
+
"request_id": get_request_id(),
|
|
99
|
+
},
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
return response
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## Error Handling Middleware
|
|
106
|
+
|
|
107
|
+
```python
|
|
108
|
+
from fastapi.responses import JSONResponse
|
|
109
|
+
|
|
110
|
+
class ErrorHandlingMiddleware(BaseHTTPMiddleware):
|
|
111
|
+
async def dispatch(self, request: Request, call_next) -> Response:
|
|
112
|
+
try:
|
|
113
|
+
return await call_next(request)
|
|
114
|
+
except ValueError as e:
|
|
115
|
+
return JSONResponse(
|
|
116
|
+
status_code=400,
|
|
117
|
+
content={"error": "Bad Request", "detail": str(e)},
|
|
118
|
+
)
|
|
119
|
+
except PermissionError as e:
|
|
120
|
+
return JSONResponse(
|
|
121
|
+
status_code=403,
|
|
122
|
+
content={"error": "Forbidden", "detail": str(e)},
|
|
123
|
+
)
|
|
124
|
+
except Exception as e:
|
|
125
|
+
logger.exception("Unhandled exception")
|
|
126
|
+
return JSONResponse(
|
|
127
|
+
status_code=500,
|
|
128
|
+
content={"error": "Internal Server Error"},
|
|
129
|
+
)
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
## Pure ASGI Middleware
|
|
133
|
+
|
|
134
|
+
```python
|
|
135
|
+
# More performant than BaseHTTPMiddleware
|
|
136
|
+
class PureASGIMiddleware:
|
|
137
|
+
def __init__(self, app):
|
|
138
|
+
self.app = app
|
|
139
|
+
|
|
140
|
+
async def __call__(self, scope, receive, send):
|
|
141
|
+
if scope["type"] != "http":
|
|
142
|
+
await self.app(scope, receive, send)
|
|
143
|
+
return
|
|
144
|
+
|
|
145
|
+
# Before request
|
|
146
|
+
start_time = time.perf_counter()
|
|
147
|
+
|
|
148
|
+
async def send_wrapper(message):
|
|
149
|
+
if message["type"] == "http.response.start":
|
|
150
|
+
# Add headers
|
|
151
|
+
headers = list(message.get("headers", []))
|
|
152
|
+
duration = time.perf_counter() - start_time
|
|
153
|
+
headers.append((b"x-process-time", f"{duration:.4f}".encode()))
|
|
154
|
+
message["headers"] = headers
|
|
155
|
+
await send(message)
|
|
156
|
+
|
|
157
|
+
await self.app(scope, receive, send_wrapper)
|
|
158
|
+
|
|
159
|
+
app.add_middleware(PureASGIMiddleware)
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
## Database Session Middleware
|
|
163
|
+
|
|
164
|
+
```python
|
|
165
|
+
from contextvars import ContextVar
|
|
166
|
+
|
|
167
|
+
db_session_ctx: ContextVar[AsyncSession | None] = ContextVar("db_session", default=None)
|
|
168
|
+
|
|
169
|
+
class DatabaseSessionMiddleware(BaseHTTPMiddleware):
|
|
170
|
+
async def dispatch(self, request: Request, call_next) -> Response:
|
|
171
|
+
async with async_session() as session:
|
|
172
|
+
db_session_ctx.set(session)
|
|
173
|
+
try:
|
|
174
|
+
response = await call_next(request)
|
|
175
|
+
await session.commit()
|
|
176
|
+
except Exception:
|
|
177
|
+
await session.rollback()
|
|
178
|
+
raise
|
|
179
|
+
finally:
|
|
180
|
+
db_session_ctx.set(None)
|
|
181
|
+
|
|
182
|
+
return response
|
|
183
|
+
|
|
184
|
+
def get_db() -> AsyncSession:
|
|
185
|
+
session = db_session_ctx.get()
|
|
186
|
+
if session is None:
|
|
187
|
+
raise RuntimeError("No database session available")
|
|
188
|
+
return session
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
## Middleware Order
|
|
192
|
+
|
|
193
|
+
```python
|
|
194
|
+
# Order matters! Last added = first executed
|
|
195
|
+
# Request flow: A -> B -> C -> Handler -> C -> B -> A
|
|
196
|
+
|
|
197
|
+
app.add_middleware(ErrorHandlingMiddleware) # 3rd (innermost)
|
|
198
|
+
app.add_middleware(LoggingMiddleware) # 2nd
|
|
199
|
+
app.add_middleware(RequestIDMiddleware) # 1st (outermost)
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
## Built-in Middleware
|
|
203
|
+
|
|
204
|
+
```python
|
|
205
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
206
|
+
from fastapi.middleware.trustedhost import TrustedHostMiddleware
|
|
207
|
+
from fastapi.middleware.gzip import GZipMiddleware
|
|
208
|
+
from fastapi.middleware.httpsredirect import HTTPSRedirectMiddleware
|
|
209
|
+
|
|
210
|
+
# HTTPS redirect
|
|
211
|
+
app.add_middleware(HTTPSRedirectMiddleware)
|
|
212
|
+
|
|
213
|
+
# Trusted hosts
|
|
214
|
+
app.add_middleware(
|
|
215
|
+
TrustedHostMiddleware,
|
|
216
|
+
allowed_hosts=["example.com", "*.example.com"],
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
# GZip compression
|
|
220
|
+
app.add_middleware(GZipMiddleware, minimum_size=1000)
|
|
221
|
+
|
|
222
|
+
# CORS
|
|
223
|
+
app.add_middleware(
|
|
224
|
+
CORSMiddleware,
|
|
225
|
+
allow_origins=["https://example.com"],
|
|
226
|
+
allow_methods=["*"],
|
|
227
|
+
allow_headers=["*"],
|
|
228
|
+
)
|
|
229
|
+
```
|
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
---
|
|
2
|
+
paths:
|
|
3
|
+
- "**/schemas/**/*.py"
|
|
4
|
+
- "**/models/**/*.py"
|
|
5
|
+
- "**/*_schema.py"
|
|
6
|
+
- "**/*_model.py"
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# Python Validation (Pydantic)
|
|
10
|
+
|
|
11
|
+
## Basic Models
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
# schemas/user.py
|
|
15
|
+
from datetime import datetime
|
|
16
|
+
from typing import Optional
|
|
17
|
+
from pydantic import BaseModel, EmailStr, Field, field_validator
|
|
18
|
+
from uuid import UUID
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class UserBase(BaseModel):
|
|
22
|
+
email: EmailStr
|
|
23
|
+
name: str = Field(..., min_length=2, max_length=100)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class UserCreate(UserBase):
|
|
27
|
+
password: str = Field(..., min_length=8)
|
|
28
|
+
|
|
29
|
+
@field_validator("password")
|
|
30
|
+
@classmethod
|
|
31
|
+
def validate_password(cls, v: str) -> str:
|
|
32
|
+
if not any(c.isupper() for c in v):
|
|
33
|
+
raise ValueError("Password must contain uppercase letter")
|
|
34
|
+
if not any(c.islower() for c in v):
|
|
35
|
+
raise ValueError("Password must contain lowercase letter")
|
|
36
|
+
if not any(c.isdigit() for c in v):
|
|
37
|
+
raise ValueError("Password must contain digit")
|
|
38
|
+
if not any(c in "!@#$%^&*" for c in v):
|
|
39
|
+
raise ValueError("Password must contain special character")
|
|
40
|
+
return v
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class UserUpdate(BaseModel):
|
|
44
|
+
name: Optional[str] = Field(None, min_length=2, max_length=100)
|
|
45
|
+
phone: Optional[str] = None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class UserResponse(UserBase):
|
|
49
|
+
id: UUID
|
|
50
|
+
is_active: bool
|
|
51
|
+
created_at: datetime
|
|
52
|
+
|
|
53
|
+
model_config = {"from_attributes": True}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Nested Models
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
# schemas/order.py
|
|
60
|
+
from pydantic import BaseModel, Field, model_validator
|
|
61
|
+
from typing import List
|
|
62
|
+
from decimal import Decimal
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class OrderItem(BaseModel):
|
|
66
|
+
product_id: UUID
|
|
67
|
+
quantity: int = Field(..., gt=0, le=100)
|
|
68
|
+
price: Decimal = Field(..., gt=0, decimal_places=2)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class OrderCreate(BaseModel):
|
|
72
|
+
items: List[OrderItem] = Field(..., min_length=1)
|
|
73
|
+
shipping_address: str = Field(..., min_length=10)
|
|
74
|
+
notes: Optional[str] = None
|
|
75
|
+
|
|
76
|
+
@model_validator(mode="after")
|
|
77
|
+
def validate_order(self) -> "OrderCreate":
|
|
78
|
+
total_items = sum(item.quantity for item in self.items)
|
|
79
|
+
if total_items > 100:
|
|
80
|
+
raise ValueError("Maximum 100 items per order")
|
|
81
|
+
return self
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class OrderResponse(BaseModel):
|
|
85
|
+
id: UUID
|
|
86
|
+
items: List[OrderItem]
|
|
87
|
+
total: Decimal
|
|
88
|
+
status: str
|
|
89
|
+
created_at: datetime
|
|
90
|
+
|
|
91
|
+
model_config = {"from_attributes": True}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Custom Types
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
# schemas/types.py
|
|
98
|
+
from typing import Annotated
|
|
99
|
+
from pydantic import AfterValidator, BeforeValidator
|
|
100
|
+
import re
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def validate_phone(v: str) -> str:
|
|
104
|
+
pattern = r"^\+?[1-9]\d{1,14}$"
|
|
105
|
+
if not re.match(pattern, v):
|
|
106
|
+
raise ValueError("Invalid phone number format")
|
|
107
|
+
return v
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def validate_slug(v: str) -> str:
|
|
111
|
+
pattern = r"^[a-z0-9]+(?:-[a-z0-9]+)*$"
|
|
112
|
+
if not re.match(pattern, v):
|
|
113
|
+
raise ValueError("Invalid slug format")
|
|
114
|
+
return v
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def normalize_email(v: str) -> str:
|
|
118
|
+
return v.lower().strip()
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
PhoneNumber = Annotated[str, AfterValidator(validate_phone)]
|
|
122
|
+
Slug = Annotated[str, AfterValidator(validate_slug)]
|
|
123
|
+
NormalizedEmail = Annotated[str, BeforeValidator(normalize_email)]
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# Usage
|
|
127
|
+
class UserCreate(BaseModel):
|
|
128
|
+
email: NormalizedEmail
|
|
129
|
+
phone: Optional[PhoneNumber] = None
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class PostCreate(BaseModel):
|
|
133
|
+
title: str
|
|
134
|
+
slug: Slug
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
## Enum Validation
|
|
138
|
+
|
|
139
|
+
```python
|
|
140
|
+
# schemas/enums.py
|
|
141
|
+
from enum import Enum
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class UserRole(str, Enum):
|
|
145
|
+
USER = "user"
|
|
146
|
+
ADMIN = "admin"
|
|
147
|
+
MODERATOR = "moderator"
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class OrderStatus(str, Enum):
|
|
151
|
+
PENDING = "pending"
|
|
152
|
+
PROCESSING = "processing"
|
|
153
|
+
SHIPPED = "shipped"
|
|
154
|
+
DELIVERED = "delivered"
|
|
155
|
+
CANCELLED = "cancelled"
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
# Usage
|
|
159
|
+
class UserCreate(BaseModel):
|
|
160
|
+
email: EmailStr
|
|
161
|
+
role: UserRole = UserRole.USER
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class OrderUpdate(BaseModel):
|
|
165
|
+
status: OrderStatus
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
## Computed Fields
|
|
169
|
+
|
|
170
|
+
```python
|
|
171
|
+
# schemas/product.py
|
|
172
|
+
from pydantic import BaseModel, computed_field
|
|
173
|
+
from decimal import Decimal
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class Product(BaseModel):
|
|
177
|
+
name: str
|
|
178
|
+
price: Decimal
|
|
179
|
+
discount_percent: int = 0
|
|
180
|
+
|
|
181
|
+
@computed_field
|
|
182
|
+
@property
|
|
183
|
+
def discounted_price(self) -> Decimal:
|
|
184
|
+
discount = self.price * self.discount_percent / 100
|
|
185
|
+
return self.price - discount
|
|
186
|
+
|
|
187
|
+
@computed_field
|
|
188
|
+
@property
|
|
189
|
+
def display_name(self) -> str:
|
|
190
|
+
if self.discount_percent > 0:
|
|
191
|
+
return f"{self.name} ({self.discount_percent}% off)"
|
|
192
|
+
return self.name
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
## Conditional Validation
|
|
196
|
+
|
|
197
|
+
```python
|
|
198
|
+
# schemas/payment.py
|
|
199
|
+
from pydantic import BaseModel, model_validator
|
|
200
|
+
from typing import Optional
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
class PaymentMethod(str, Enum):
|
|
204
|
+
CREDIT_CARD = "credit_card"
|
|
205
|
+
BANK_TRANSFER = "bank_transfer"
|
|
206
|
+
PAYPAL = "paypal"
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
class PaymentCreate(BaseModel):
|
|
210
|
+
method: PaymentMethod
|
|
211
|
+
amount: Decimal
|
|
212
|
+
|
|
213
|
+
# Credit card fields
|
|
214
|
+
card_number: Optional[str] = None
|
|
215
|
+
expiry_date: Optional[str] = None
|
|
216
|
+
cvv: Optional[str] = None
|
|
217
|
+
|
|
218
|
+
# Bank transfer fields
|
|
219
|
+
bank_account: Optional[str] = None
|
|
220
|
+
routing_number: Optional[str] = None
|
|
221
|
+
|
|
222
|
+
# PayPal fields
|
|
223
|
+
paypal_email: Optional[EmailStr] = None
|
|
224
|
+
|
|
225
|
+
@model_validator(mode="after")
|
|
226
|
+
def validate_payment_details(self) -> "PaymentCreate":
|
|
227
|
+
if self.method == PaymentMethod.CREDIT_CARD:
|
|
228
|
+
if not all([self.card_number, self.expiry_date, self.cvv]):
|
|
229
|
+
raise ValueError("Card details required for credit card payment")
|
|
230
|
+
|
|
231
|
+
elif self.method == PaymentMethod.BANK_TRANSFER:
|
|
232
|
+
if not all([self.bank_account, self.routing_number]):
|
|
233
|
+
raise ValueError("Bank details required for bank transfer")
|
|
234
|
+
|
|
235
|
+
elif self.method == PaymentMethod.PAYPAL:
|
|
236
|
+
if not self.paypal_email:
|
|
237
|
+
raise ValueError("PayPal email required")
|
|
238
|
+
|
|
239
|
+
return self
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
## Generic Pagination
|
|
243
|
+
|
|
244
|
+
```python
|
|
245
|
+
# schemas/pagination.py
|
|
246
|
+
from typing import Generic, TypeVar, List
|
|
247
|
+
from pydantic import BaseModel
|
|
248
|
+
|
|
249
|
+
T = TypeVar("T")
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
class PaginatedResponse(BaseModel, Generic[T]):
|
|
253
|
+
items: List[T]
|
|
254
|
+
total: int
|
|
255
|
+
page: int
|
|
256
|
+
size: int
|
|
257
|
+
pages: int
|
|
258
|
+
|
|
259
|
+
@classmethod
|
|
260
|
+
def create(
|
|
261
|
+
cls,
|
|
262
|
+
items: List[T],
|
|
263
|
+
total: int,
|
|
264
|
+
page: int,
|
|
265
|
+
size: int,
|
|
266
|
+
) -> "PaginatedResponse[T]":
|
|
267
|
+
return cls(
|
|
268
|
+
items=items,
|
|
269
|
+
total=total,
|
|
270
|
+
page=page,
|
|
271
|
+
size=size,
|
|
272
|
+
pages=(total + size - 1) // size,
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
class PaginationParams(BaseModel):
|
|
277
|
+
page: int = Field(1, ge=1)
|
|
278
|
+
size: int = Field(10, ge=1, le=100)
|
|
279
|
+
|
|
280
|
+
@property
|
|
281
|
+
def offset(self) -> int:
|
|
282
|
+
return (self.page - 1) * self.size
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
# Usage
|
|
286
|
+
# PaginatedResponse[UserResponse]
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
## FastAPI Integration
|
|
290
|
+
|
|
291
|
+
```python
|
|
292
|
+
# api/users.py
|
|
293
|
+
from fastapi import APIRouter, HTTPException, Depends, Query
|
|
294
|
+
from pydantic import ValidationError
|
|
295
|
+
|
|
296
|
+
router = APIRouter(prefix="/users", tags=["users"])
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
@router.post("/", response_model=UserResponse, status_code=201)
|
|
300
|
+
async def create_user(
|
|
301
|
+
user: UserCreate,
|
|
302
|
+
db: AsyncSession = Depends(get_db),
|
|
303
|
+
) -> UserResponse:
|
|
304
|
+
# Validation already done by Pydantic
|
|
305
|
+
existing = await db.execute(
|
|
306
|
+
select(User).where(User.email == user.email)
|
|
307
|
+
)
|
|
308
|
+
if existing.scalar_one_or_none():
|
|
309
|
+
raise HTTPException(status_code=409, detail="Email already registered")
|
|
310
|
+
|
|
311
|
+
new_user = User(**user.model_dump())
|
|
312
|
+
db.add(new_user)
|
|
313
|
+
await db.commit()
|
|
314
|
+
await db.refresh(new_user)
|
|
315
|
+
|
|
316
|
+
return UserResponse.model_validate(new_user)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
@router.get("/", response_model=PaginatedResponse[UserResponse])
|
|
320
|
+
async def list_users(
|
|
321
|
+
pagination: PaginationParams = Depends(),
|
|
322
|
+
search: Optional[str] = Query(None, min_length=1),
|
|
323
|
+
db: AsyncSession = Depends(get_db),
|
|
324
|
+
) -> PaginatedResponse[UserResponse]:
|
|
325
|
+
query = select(User)
|
|
326
|
+
|
|
327
|
+
if search:
|
|
328
|
+
query = query.where(User.name.ilike(f"%{search}%"))
|
|
329
|
+
|
|
330
|
+
total = await db.scalar(select(func.count()).select_from(query.subquery()))
|
|
331
|
+
users = await db.scalars(
|
|
332
|
+
query.offset(pagination.offset).limit(pagination.size)
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
return PaginatedResponse.create(
|
|
336
|
+
items=[UserResponse.model_validate(u) for u in users],
|
|
337
|
+
total=total,
|
|
338
|
+
page=pagination.page,
|
|
339
|
+
size=pagination.size,
|
|
340
|
+
)
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
## Custom Error Messages
|
|
344
|
+
|
|
345
|
+
```python
|
|
346
|
+
# schemas/user.py
|
|
347
|
+
from pydantic import BaseModel, Field
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
class UserCreate(BaseModel):
|
|
351
|
+
email: EmailStr = Field(
|
|
352
|
+
...,
|
|
353
|
+
description="User email address",
|
|
354
|
+
json_schema_extra={"example": "user@example.com"},
|
|
355
|
+
)
|
|
356
|
+
password: str = Field(
|
|
357
|
+
...,
|
|
358
|
+
min_length=8,
|
|
359
|
+
max_length=100,
|
|
360
|
+
description="User password (min 8 characters)",
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
model_config = {
|
|
364
|
+
"json_schema_extra": {
|
|
365
|
+
"examples": [
|
|
366
|
+
{
|
|
367
|
+
"email": "john@example.com",
|
|
368
|
+
"password": "SecurePass123!",
|
|
369
|
+
}
|
|
370
|
+
]
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
```
|
|
374
|
+
|
|
375
|
+
## Anti-patterns
|
|
376
|
+
|
|
377
|
+
```python
|
|
378
|
+
# BAD: Using dict instead of Pydantic model
|
|
379
|
+
@router.post("/users")
|
|
380
|
+
async def create_user(data: dict): # No validation!
|
|
381
|
+
email = data.get("email")
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
# GOOD: Use Pydantic models
|
|
385
|
+
@router.post("/users")
|
|
386
|
+
async def create_user(data: UserCreate):
|
|
387
|
+
email = data.email # Validated and typed
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
# BAD: Manual validation
|
|
391
|
+
class UserCreate(BaseModel):
|
|
392
|
+
email: str
|
|
393
|
+
|
|
394
|
+
def validate_email(self):
|
|
395
|
+
if "@" not in self.email:
|
|
396
|
+
raise ValueError("Invalid email")
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
# GOOD: Use built-in validators
|
|
400
|
+
class UserCreate(BaseModel):
|
|
401
|
+
email: EmailStr # Automatic validation
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
# BAD: Catching all exceptions
|
|
405
|
+
try:
|
|
406
|
+
user = UserCreate(**data)
|
|
407
|
+
except Exception as e:
|
|
408
|
+
return {"error": str(e)}
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
# GOOD: Catch specific validation errors
|
|
412
|
+
from pydantic import ValidationError
|
|
413
|
+
|
|
414
|
+
try:
|
|
415
|
+
user = UserCreate(**data)
|
|
416
|
+
except ValidationError as e:
|
|
417
|
+
return {"errors": e.errors()}
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
# BAD: Not using model_config for ORM
|
|
421
|
+
class UserResponse(BaseModel):
|
|
422
|
+
id: UUID
|
|
423
|
+
name: str
|
|
424
|
+
# This won't work with SQLAlchemy objects
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
# GOOD: Enable from_attributes
|
|
428
|
+
class UserResponse(BaseModel):
|
|
429
|
+
id: UUID
|
|
430
|
+
name: str
|
|
431
|
+
|
|
432
|
+
model_config = {"from_attributes": True}
|
|
433
|
+
```
|