@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,170 @@
|
|
|
1
|
+
---
|
|
2
|
+
paths:
|
|
3
|
+
- "**/*.py"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# FastAPI Dependency Injection
|
|
7
|
+
|
|
8
|
+
## Basic Dependencies
|
|
9
|
+
|
|
10
|
+
```python
|
|
11
|
+
# GOOD - reusable dependency
|
|
12
|
+
from typing import Annotated
|
|
13
|
+
from fastapi import Depends
|
|
14
|
+
|
|
15
|
+
async def get_db():
|
|
16
|
+
async with async_session() as session:
|
|
17
|
+
yield session
|
|
18
|
+
|
|
19
|
+
DbSession = Annotated[AsyncSession, Depends(get_db)]
|
|
20
|
+
|
|
21
|
+
@router.get("/users/{user_id}")
|
|
22
|
+
async def get_user(user_id: int, db: DbSession) -> User:
|
|
23
|
+
return await db.get(User, user_id)
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Dependency with Parameters
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
# GOOD - parameterized dependency
|
|
30
|
+
def get_pagination(
|
|
31
|
+
page: int = Query(1, ge=1),
|
|
32
|
+
size: int = Query(20, ge=1, le=100),
|
|
33
|
+
) -> Pagination:
|
|
34
|
+
return Pagination(page=page, size=size)
|
|
35
|
+
|
|
36
|
+
Paginated = Annotated[Pagination, Depends(get_pagination)]
|
|
37
|
+
|
|
38
|
+
@router.get("/users")
|
|
39
|
+
async def list_users(pagination: Paginated) -> Page[User]:
|
|
40
|
+
...
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Class-Based Dependencies
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
# GOOD - stateful dependency
|
|
47
|
+
class RateLimiter:
|
|
48
|
+
def __init__(self, requests_per_minute: int):
|
|
49
|
+
self.rpm = requests_per_minute
|
|
50
|
+
self.requests: dict[str, list[float]] = {}
|
|
51
|
+
|
|
52
|
+
async def __call__(self, request: Request) -> None:
|
|
53
|
+
client_ip = request.client.host
|
|
54
|
+
now = time.time()
|
|
55
|
+
|
|
56
|
+
# Clean old requests
|
|
57
|
+
self.requests[client_ip] = [
|
|
58
|
+
t for t in self.requests.get(client_ip, [])
|
|
59
|
+
if now - t < 60
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
if len(self.requests[client_ip]) >= self.rpm:
|
|
63
|
+
raise HTTPException(429, "Rate limit exceeded")
|
|
64
|
+
|
|
65
|
+
self.requests[client_ip].append(now)
|
|
66
|
+
|
|
67
|
+
rate_limiter = RateLimiter(requests_per_minute=60)
|
|
68
|
+
|
|
69
|
+
@router.get("/api/data", dependencies=[Depends(rate_limiter)])
|
|
70
|
+
async def get_data():
|
|
71
|
+
...
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Authentication Dependencies
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
# GOOD - reusable auth dependency
|
|
78
|
+
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/token")
|
|
79
|
+
|
|
80
|
+
async def get_current_user(
|
|
81
|
+
token: Annotated[str, Depends(oauth2_scheme)],
|
|
82
|
+
db: DbSession,
|
|
83
|
+
) -> User:
|
|
84
|
+
payload = verify_token(token)
|
|
85
|
+
if not payload:
|
|
86
|
+
raise HTTPException(401, "Invalid token")
|
|
87
|
+
|
|
88
|
+
user = await db.get(User, payload["sub"])
|
|
89
|
+
if not user:
|
|
90
|
+
raise HTTPException(401, "User not found")
|
|
91
|
+
|
|
92
|
+
return user
|
|
93
|
+
|
|
94
|
+
CurrentUser = Annotated[User, Depends(get_current_user)]
|
|
95
|
+
|
|
96
|
+
# Role-based dependency
|
|
97
|
+
def require_role(role: str):
|
|
98
|
+
async def check_role(user: CurrentUser) -> User:
|
|
99
|
+
if user.role != role:
|
|
100
|
+
raise HTTPException(403, "Insufficient permissions")
|
|
101
|
+
return user
|
|
102
|
+
return check_role
|
|
103
|
+
|
|
104
|
+
AdminUser = Annotated[User, Depends(require_role("admin"))]
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## Dependency Chaining
|
|
108
|
+
|
|
109
|
+
```python
|
|
110
|
+
# Dependencies can depend on other dependencies
|
|
111
|
+
async def get_user_service(db: DbSession) -> UserService:
|
|
112
|
+
return UserService(db)
|
|
113
|
+
|
|
114
|
+
async def get_current_user_with_service(
|
|
115
|
+
token: Annotated[str, Depends(oauth2_scheme)],
|
|
116
|
+
service: Annotated[UserService, Depends(get_user_service)],
|
|
117
|
+
) -> User:
|
|
118
|
+
return await service.get_by_token(token)
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
## Global Dependencies
|
|
122
|
+
|
|
123
|
+
```python
|
|
124
|
+
# Apply to all routes in router
|
|
125
|
+
router = APIRouter(
|
|
126
|
+
prefix="/admin",
|
|
127
|
+
dependencies=[Depends(require_role("admin"))],
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
# Apply to entire app
|
|
131
|
+
app = FastAPI(dependencies=[Depends(verify_api_key)])
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
## Yield Dependencies (Context Managers)
|
|
135
|
+
|
|
136
|
+
```python
|
|
137
|
+
# GOOD - cleanup after request
|
|
138
|
+
async def get_db_transaction():
|
|
139
|
+
async with async_session() as session:
|
|
140
|
+
async with session.begin():
|
|
141
|
+
yield session
|
|
142
|
+
# Commit happens automatically if no exception
|
|
143
|
+
# Rollback happens automatically on exception
|
|
144
|
+
|
|
145
|
+
# GOOD - resource cleanup
|
|
146
|
+
async def get_temp_file():
|
|
147
|
+
path = Path(tempfile.mktemp())
|
|
148
|
+
try:
|
|
149
|
+
yield path
|
|
150
|
+
finally:
|
|
151
|
+
path.unlink(missing_ok=True)
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
## Testing Dependencies
|
|
155
|
+
|
|
156
|
+
```python
|
|
157
|
+
# Override dependencies in tests
|
|
158
|
+
from fastapi.testclient import TestClient
|
|
159
|
+
|
|
160
|
+
def get_test_db():
|
|
161
|
+
return TestDatabase()
|
|
162
|
+
|
|
163
|
+
app.dependency_overrides[get_db] = get_test_db
|
|
164
|
+
|
|
165
|
+
with TestClient(app) as client:
|
|
166
|
+
response = client.get("/users")
|
|
167
|
+
|
|
168
|
+
# Clean up
|
|
169
|
+
app.dependency_overrides.clear()
|
|
170
|
+
```
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
---
|
|
2
2
|
paths:
|
|
3
|
-
- "
|
|
3
|
+
- "**/routers/**/*.py"
|
|
4
|
+
- "**/routes/**/*.py"
|
|
5
|
+
- "**/api/**/*.py"
|
|
6
|
+
- "**/endpoints/**/*.py"
|
|
7
|
+
- "**/main.py"
|
|
8
|
+
- "**/app.py"
|
|
4
9
|
---
|
|
5
10
|
|
|
6
11
|
# FastAPI Rules
|
|
@@ -270,3 +275,58 @@ app = FastAPI(
|
|
|
270
275
|
if settings.environment == "production":
|
|
271
276
|
app = FastAPI(docs_url=None, redoc_url=None)
|
|
272
277
|
```
|
|
278
|
+
|
|
279
|
+
## Type Hints
|
|
280
|
+
|
|
281
|
+
Always use modern syntax (Python 3.10+):
|
|
282
|
+
|
|
283
|
+
```python
|
|
284
|
+
# Modern type hints
|
|
285
|
+
async def get_user(user_id: int) -> User | None:
|
|
286
|
+
...
|
|
287
|
+
|
|
288
|
+
def process_items(items: list[str]) -> dict[str, int]:
|
|
289
|
+
...
|
|
290
|
+
|
|
291
|
+
# Annotated for dependency injection
|
|
292
|
+
DbSession = Annotated[AsyncSession, Depends(get_db)]
|
|
293
|
+
CurrentUser = Annotated[User, Depends(get_current_user)]
|
|
294
|
+
|
|
295
|
+
@router.get("/{user_id}")
|
|
296
|
+
async def get_user(
|
|
297
|
+
user_id: int,
|
|
298
|
+
db: DbSession,
|
|
299
|
+
current_user: CurrentUser,
|
|
300
|
+
) -> UserResponse:
|
|
301
|
+
...
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
## Custom Exception Handling
|
|
305
|
+
|
|
306
|
+
```python
|
|
307
|
+
# Custom exceptions
|
|
308
|
+
class NotFoundError(Exception):
|
|
309
|
+
def __init__(self, resource: str, id: int):
|
|
310
|
+
self.resource = resource
|
|
311
|
+
self.id = id
|
|
312
|
+
|
|
313
|
+
class BusinessError(Exception):
|
|
314
|
+
def __init__(self, message: str, code: str):
|
|
315
|
+
self.message = message
|
|
316
|
+
self.code = code
|
|
317
|
+
|
|
318
|
+
# FastAPI exception handlers
|
|
319
|
+
@app.exception_handler(NotFoundError)
|
|
320
|
+
async def not_found_handler(request: Request, exc: NotFoundError):
|
|
321
|
+
return JSONResponse(
|
|
322
|
+
status_code=404,
|
|
323
|
+
content={"detail": f"{exc.resource} {exc.id} not found"}
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
@app.exception_handler(BusinessError)
|
|
327
|
+
async def business_error_handler(request: Request, exc: BusinessError):
|
|
328
|
+
return JSONResponse(
|
|
329
|
+
status_code=422,
|
|
330
|
+
content={"detail": exc.message, "code": exc.code}
|
|
331
|
+
)
|
|
332
|
+
```
|
|
@@ -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
|
+
```
|