@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,274 @@
|
|
|
1
|
+
---
|
|
2
|
+
paths:
|
|
3
|
+
- "**/*.py"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# FastAPI Lifespan Management
|
|
7
|
+
|
|
8
|
+
## Lifespan Context Manager
|
|
9
|
+
|
|
10
|
+
```python
|
|
11
|
+
from contextlib import asynccontextmanager
|
|
12
|
+
from fastapi import FastAPI
|
|
13
|
+
|
|
14
|
+
@asynccontextmanager
|
|
15
|
+
async def lifespan(app: FastAPI):
|
|
16
|
+
# Startup: runs before app starts accepting requests
|
|
17
|
+
print("Starting up...")
|
|
18
|
+
app.state.db = await create_database_pool()
|
|
19
|
+
app.state.redis = await create_redis_pool()
|
|
20
|
+
|
|
21
|
+
yield # App runs here
|
|
22
|
+
|
|
23
|
+
# Shutdown: runs when app is shutting down
|
|
24
|
+
print("Shutting down...")
|
|
25
|
+
await app.state.db.close()
|
|
26
|
+
await app.state.redis.close()
|
|
27
|
+
|
|
28
|
+
app = FastAPI(lifespan=lifespan)
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Database Connection Pool
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
|
|
35
|
+
|
|
36
|
+
@asynccontextmanager
|
|
37
|
+
async def lifespan(app: FastAPI):
|
|
38
|
+
# Create engine
|
|
39
|
+
engine = create_async_engine(
|
|
40
|
+
settings.database_url,
|
|
41
|
+
pool_size=10,
|
|
42
|
+
max_overflow=20,
|
|
43
|
+
pool_pre_ping=True,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
# Create session factory
|
|
47
|
+
app.state.async_session = async_sessionmaker(
|
|
48
|
+
engine,
|
|
49
|
+
expire_on_commit=False,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
yield
|
|
53
|
+
|
|
54
|
+
# Dispose engine
|
|
55
|
+
await engine.dispose()
|
|
56
|
+
|
|
57
|
+
# Dependency
|
|
58
|
+
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
|
59
|
+
async with app.state.async_session() as session:
|
|
60
|
+
yield session
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Redis Connection
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
import redis.asyncio as redis
|
|
67
|
+
|
|
68
|
+
@asynccontextmanager
|
|
69
|
+
async def lifespan(app: FastAPI):
|
|
70
|
+
# Redis pool
|
|
71
|
+
app.state.redis = await redis.from_url(
|
|
72
|
+
settings.redis_url,
|
|
73
|
+
encoding="utf-8",
|
|
74
|
+
decode_responses=True,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
yield
|
|
78
|
+
|
|
79
|
+
await app.state.redis.close()
|
|
80
|
+
|
|
81
|
+
# Usage
|
|
82
|
+
@router.get("/cached/{key}")
|
|
83
|
+
async def get_cached(key: str, request: Request) -> dict:
|
|
84
|
+
value = await request.app.state.redis.get(key)
|
|
85
|
+
return {"key": key, "value": value}
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## HTTP Client Pool
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
import httpx
|
|
92
|
+
|
|
93
|
+
@asynccontextmanager
|
|
94
|
+
async def lifespan(app: FastAPI):
|
|
95
|
+
# Create shared HTTP client
|
|
96
|
+
app.state.http_client = httpx.AsyncClient(
|
|
97
|
+
timeout=30.0,
|
|
98
|
+
limits=httpx.Limits(max_connections=100),
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
yield
|
|
102
|
+
|
|
103
|
+
await app.state.http_client.aclose()
|
|
104
|
+
|
|
105
|
+
# Usage
|
|
106
|
+
async def call_external_api(request: Request) -> dict:
|
|
107
|
+
client = request.app.state.http_client
|
|
108
|
+
response = await client.get("https://api.example.com/data")
|
|
109
|
+
return response.json()
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## Background Task Scheduler
|
|
113
|
+
|
|
114
|
+
```python
|
|
115
|
+
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
|
116
|
+
|
|
117
|
+
@asynccontextmanager
|
|
118
|
+
async def lifespan(app: FastAPI):
|
|
119
|
+
scheduler = AsyncIOScheduler()
|
|
120
|
+
|
|
121
|
+
# Add scheduled jobs
|
|
122
|
+
scheduler.add_job(
|
|
123
|
+
cleanup_expired_sessions,
|
|
124
|
+
"interval",
|
|
125
|
+
hours=1,
|
|
126
|
+
id="cleanup_sessions",
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
scheduler.add_job(
|
|
130
|
+
send_daily_reports,
|
|
131
|
+
"cron",
|
|
132
|
+
hour=9,
|
|
133
|
+
minute=0,
|
|
134
|
+
id="daily_reports",
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
scheduler.start()
|
|
138
|
+
app.state.scheduler = scheduler
|
|
139
|
+
|
|
140
|
+
yield
|
|
141
|
+
|
|
142
|
+
scheduler.shutdown(wait=True)
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
## Multiple Resources
|
|
146
|
+
|
|
147
|
+
```python
|
|
148
|
+
@asynccontextmanager
|
|
149
|
+
async def lifespan(app: FastAPI):
|
|
150
|
+
# Initialize all resources
|
|
151
|
+
engine = create_async_engine(settings.database_url)
|
|
152
|
+
redis_pool = await redis.from_url(settings.redis_url)
|
|
153
|
+
http_client = httpx.AsyncClient()
|
|
154
|
+
|
|
155
|
+
# Store in app state
|
|
156
|
+
app.state.db_engine = engine
|
|
157
|
+
app.state.async_session = async_sessionmaker(engine)
|
|
158
|
+
app.state.redis = redis_pool
|
|
159
|
+
app.state.http_client = http_client
|
|
160
|
+
|
|
161
|
+
# Run migrations
|
|
162
|
+
async with engine.begin() as conn:
|
|
163
|
+
await conn.run_sync(Base.metadata.create_all)
|
|
164
|
+
|
|
165
|
+
# Log startup
|
|
166
|
+
logger.info("Application started", extra={
|
|
167
|
+
"database": settings.database_url,
|
|
168
|
+
"redis": settings.redis_url,
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
yield
|
|
172
|
+
|
|
173
|
+
# Cleanup in reverse order
|
|
174
|
+
await http_client.aclose()
|
|
175
|
+
await redis_pool.close()
|
|
176
|
+
await engine.dispose()
|
|
177
|
+
|
|
178
|
+
logger.info("Application shutdown complete")
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
## Health Checks with Lifespan
|
|
182
|
+
|
|
183
|
+
```python
|
|
184
|
+
from dataclasses import dataclass, field
|
|
185
|
+
from datetime import datetime
|
|
186
|
+
|
|
187
|
+
@dataclass
|
|
188
|
+
class AppHealth:
|
|
189
|
+
started_at: datetime = field(default_factory=datetime.utcnow)
|
|
190
|
+
ready: bool = False
|
|
191
|
+
checks: dict = field(default_factory=dict)
|
|
192
|
+
|
|
193
|
+
@asynccontextmanager
|
|
194
|
+
async def lifespan(app: FastAPI):
|
|
195
|
+
health = AppHealth()
|
|
196
|
+
app.state.health = health
|
|
197
|
+
|
|
198
|
+
# Initialize resources
|
|
199
|
+
try:
|
|
200
|
+
app.state.db = await create_database_pool()
|
|
201
|
+
health.checks["database"] = "ok"
|
|
202
|
+
except Exception as e:
|
|
203
|
+
health.checks["database"] = f"error: {e}"
|
|
204
|
+
|
|
205
|
+
try:
|
|
206
|
+
app.state.redis = await create_redis_pool()
|
|
207
|
+
health.checks["redis"] = "ok"
|
|
208
|
+
except Exception as e:
|
|
209
|
+
health.checks["redis"] = f"error: {e}"
|
|
210
|
+
|
|
211
|
+
health.ready = all(v == "ok" for v in health.checks.values())
|
|
212
|
+
|
|
213
|
+
yield
|
|
214
|
+
|
|
215
|
+
health.ready = False
|
|
216
|
+
await cleanup_resources(app)
|
|
217
|
+
|
|
218
|
+
@router.get("/health/live")
|
|
219
|
+
async def liveness() -> dict:
|
|
220
|
+
return {"status": "ok"}
|
|
221
|
+
|
|
222
|
+
@router.get("/health/ready")
|
|
223
|
+
async def readiness(request: Request) -> dict:
|
|
224
|
+
health = request.app.state.health
|
|
225
|
+
|
|
226
|
+
if not health.ready:
|
|
227
|
+
raise HTTPException(503, detail=health.checks)
|
|
228
|
+
|
|
229
|
+
return {
|
|
230
|
+
"status": "ready",
|
|
231
|
+
"uptime": (datetime.utcnow() - health.started_at).total_seconds(),
|
|
232
|
+
"checks": health.checks,
|
|
233
|
+
}
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
## Deprecated: on_event
|
|
237
|
+
|
|
238
|
+
```python
|
|
239
|
+
# DEPRECATED - Don't use this pattern
|
|
240
|
+
@app.on_event("startup")
|
|
241
|
+
async def startup():
|
|
242
|
+
...
|
|
243
|
+
|
|
244
|
+
@app.on_event("shutdown")
|
|
245
|
+
async def shutdown():
|
|
246
|
+
...
|
|
247
|
+
|
|
248
|
+
# Use lifespan instead (see above)
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
## Testing with Lifespan
|
|
252
|
+
|
|
253
|
+
```python
|
|
254
|
+
import pytest
|
|
255
|
+
from httpx import AsyncClient, ASGITransport
|
|
256
|
+
|
|
257
|
+
@pytest.fixture
|
|
258
|
+
async def client():
|
|
259
|
+
# Lifespan is automatically handled
|
|
260
|
+
async with AsyncClient(
|
|
261
|
+
transport=ASGITransport(app=app),
|
|
262
|
+
base_url="http://test",
|
|
263
|
+
) as client:
|
|
264
|
+
yield client
|
|
265
|
+
|
|
266
|
+
# Or override lifespan for tests
|
|
267
|
+
@asynccontextmanager
|
|
268
|
+
async def test_lifespan(app: FastAPI):
|
|
269
|
+
app.state.db = MockDatabase()
|
|
270
|
+
app.state.redis = MockRedis()
|
|
271
|
+
yield
|
|
272
|
+
|
|
273
|
+
app_for_testing = FastAPI(lifespan=test_lifespan)
|
|
274
|
+
```
|
|
@@ -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
|
+
```
|