@malamute/ai-rules 1.0.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 +174 -0
- package/bin/cli.js +5 -0
- package/configs/_shared/.claude/commands/fix-issue.md +38 -0
- package/configs/_shared/.claude/commands/generate-tests.md +49 -0
- package/configs/_shared/.claude/commands/review-pr.md +77 -0
- package/configs/_shared/.claude/rules/accessibility.md +270 -0
- package/configs/_shared/.claude/rules/performance.md +226 -0
- package/configs/_shared/.claude/rules/security.md +188 -0
- package/configs/_shared/.claude/skills/debug/SKILL.md +118 -0
- package/configs/_shared/.claude/skills/learning/SKILL.md +224 -0
- package/configs/_shared/.claude/skills/review/SKILL.md +86 -0
- package/configs/_shared/.claude/skills/spec/SKILL.md +112 -0
- package/configs/_shared/CLAUDE.md +174 -0
- package/configs/angular/.claude/rules/components.md +257 -0
- package/configs/angular/.claude/rules/state.md +250 -0
- package/configs/angular/.claude/rules/testing.md +422 -0
- package/configs/angular/.claude/settings.json +31 -0
- package/configs/angular/CLAUDE.md +251 -0
- package/configs/dotnet/.claude/rules/api.md +370 -0
- package/configs/dotnet/.claude/rules/architecture.md +199 -0
- package/configs/dotnet/.claude/rules/database/efcore.md +408 -0
- package/configs/dotnet/.claude/rules/testing.md +389 -0
- package/configs/dotnet/.claude/settings.json +9 -0
- package/configs/dotnet/CLAUDE.md +319 -0
- package/configs/nestjs/.claude/rules/auth.md +321 -0
- package/configs/nestjs/.claude/rules/database/prisma.md +305 -0
- package/configs/nestjs/.claude/rules/database/typeorm.md +379 -0
- package/configs/nestjs/.claude/rules/modules.md +215 -0
- package/configs/nestjs/.claude/rules/testing.md +315 -0
- package/configs/nestjs/.claude/rules/validation.md +279 -0
- package/configs/nestjs/.claude/settings.json +15 -0
- package/configs/nestjs/CLAUDE.md +263 -0
- package/configs/nextjs/.claude/rules/components.md +211 -0
- package/configs/nextjs/.claude/rules/state/redux-toolkit.md +429 -0
- package/configs/nextjs/.claude/rules/state/zustand.md +299 -0
- package/configs/nextjs/.claude/rules/testing.md +315 -0
- package/configs/nextjs/.claude/settings.json +29 -0
- package/configs/nextjs/CLAUDE.md +376 -0
- package/configs/python/.claude/rules/database/sqlalchemy.md +355 -0
- package/configs/python/.claude/rules/fastapi.md +272 -0
- package/configs/python/.claude/rules/flask.md +332 -0
- package/configs/python/.claude/rules/testing.md +374 -0
- package/configs/python/.claude/settings.json +18 -0
- package/configs/python/CLAUDE.md +273 -0
- package/package.json +41 -0
- package/src/install.js +315 -0
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
---
|
|
2
|
+
paths:
|
|
3
|
+
- "**/*.py"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# SQLAlchemy 2.0 Rules
|
|
7
|
+
|
|
8
|
+
## Async Setup
|
|
9
|
+
|
|
10
|
+
```python
|
|
11
|
+
from sqlalchemy.ext.asyncio import (
|
|
12
|
+
create_async_engine,
|
|
13
|
+
AsyncSession,
|
|
14
|
+
async_sessionmaker,
|
|
15
|
+
)
|
|
16
|
+
from sqlalchemy.orm import DeclarativeBase
|
|
17
|
+
|
|
18
|
+
DATABASE_URL = "postgresql+asyncpg://user:pass@localhost/db"
|
|
19
|
+
|
|
20
|
+
engine = create_async_engine(DATABASE_URL, echo=False)
|
|
21
|
+
|
|
22
|
+
async_session_maker = async_sessionmaker(
|
|
23
|
+
engine,
|
|
24
|
+
class_=AsyncSession,
|
|
25
|
+
expire_on_commit=False,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
class Base(DeclarativeBase):
|
|
29
|
+
pass
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Model Definitions (SQLAlchemy 2.0 style)
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
from datetime import datetime
|
|
36
|
+
from sqlalchemy import String, ForeignKey, func
|
|
37
|
+
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
38
|
+
|
|
39
|
+
class TimestampMixin:
|
|
40
|
+
created_at: Mapped[datetime] = mapped_column(
|
|
41
|
+
default=func.now(),
|
|
42
|
+
server_default=func.now(),
|
|
43
|
+
)
|
|
44
|
+
updated_at: Mapped[datetime | None] = mapped_column(
|
|
45
|
+
onupdate=func.now(),
|
|
46
|
+
default=None,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
class User(TimestampMixin, Base):
|
|
50
|
+
__tablename__ = "users"
|
|
51
|
+
|
|
52
|
+
id: Mapped[int] = mapped_column(primary_key=True)
|
|
53
|
+
email: Mapped[str] = mapped_column(String(256), unique=True, index=True)
|
|
54
|
+
password_hash: Mapped[str] = mapped_column(String(256))
|
|
55
|
+
name: Mapped[str | None] = mapped_column(String(100))
|
|
56
|
+
role: Mapped[str] = mapped_column(String(50), default="user")
|
|
57
|
+
is_active: Mapped[bool] = mapped_column(default=True)
|
|
58
|
+
|
|
59
|
+
# Relationships
|
|
60
|
+
posts: Mapped[list["Post"]] = relationship(
|
|
61
|
+
back_populates="author",
|
|
62
|
+
lazy="selectin",
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
def __repr__(self) -> str:
|
|
66
|
+
return f"<User(id={self.id}, email={self.email})>"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class Post(TimestampMixin, Base):
|
|
70
|
+
__tablename__ = "posts"
|
|
71
|
+
|
|
72
|
+
id: Mapped[int] = mapped_column(primary_key=True)
|
|
73
|
+
title: Mapped[str] = mapped_column(String(200))
|
|
74
|
+
content: Mapped[str | None]
|
|
75
|
+
published: Mapped[bool] = mapped_column(default=False)
|
|
76
|
+
|
|
77
|
+
# Foreign key
|
|
78
|
+
author_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
|
|
79
|
+
author: Mapped["User"] = relationship(back_populates="posts")
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Repository Pattern
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
from sqlalchemy import select, delete, update
|
|
86
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
87
|
+
|
|
88
|
+
class BaseRepository[T]:
|
|
89
|
+
def __init__(self, session: AsyncSession, model: type[T]):
|
|
90
|
+
self.session = session
|
|
91
|
+
self.model = model
|
|
92
|
+
|
|
93
|
+
async def get(self, id: int) -> T | None:
|
|
94
|
+
return await self.session.get(self.model, id)
|
|
95
|
+
|
|
96
|
+
async def get_all(
|
|
97
|
+
self,
|
|
98
|
+
skip: int = 0,
|
|
99
|
+
limit: int = 100,
|
|
100
|
+
) -> list[T]:
|
|
101
|
+
stmt = select(self.model).offset(skip).limit(limit)
|
|
102
|
+
result = await self.session.execute(stmt)
|
|
103
|
+
return list(result.scalars().all())
|
|
104
|
+
|
|
105
|
+
async def create(self, **kwargs) -> T:
|
|
106
|
+
instance = self.model(**kwargs)
|
|
107
|
+
self.session.add(instance)
|
|
108
|
+
await self.session.flush()
|
|
109
|
+
await self.session.refresh(instance)
|
|
110
|
+
return instance
|
|
111
|
+
|
|
112
|
+
async def update(self, id: int, **kwargs) -> T | None:
|
|
113
|
+
stmt = (
|
|
114
|
+
update(self.model)
|
|
115
|
+
.where(self.model.id == id)
|
|
116
|
+
.values(**kwargs)
|
|
117
|
+
.returning(self.model)
|
|
118
|
+
)
|
|
119
|
+
result = await self.session.execute(stmt)
|
|
120
|
+
return result.scalar_one_or_none()
|
|
121
|
+
|
|
122
|
+
async def delete(self, id: int) -> bool:
|
|
123
|
+
stmt = delete(self.model).where(self.model.id == id)
|
|
124
|
+
result = await self.session.execute(stmt)
|
|
125
|
+
return result.rowcount > 0
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class UserRepository(BaseRepository[User]):
|
|
129
|
+
def __init__(self, session: AsyncSession):
|
|
130
|
+
super().__init__(session, User)
|
|
131
|
+
|
|
132
|
+
async def get_by_email(self, email: str) -> User | None:
|
|
133
|
+
stmt = select(User).where(User.email == email)
|
|
134
|
+
result = await self.session.execute(stmt)
|
|
135
|
+
return result.scalar_one_or_none()
|
|
136
|
+
|
|
137
|
+
async def get_active_users(self) -> list[User]:
|
|
138
|
+
stmt = select(User).where(User.is_active == True)
|
|
139
|
+
result = await self.session.execute(stmt)
|
|
140
|
+
return list(result.scalars().all())
|
|
141
|
+
|
|
142
|
+
async def search(
|
|
143
|
+
self,
|
|
144
|
+
query: str,
|
|
145
|
+
skip: int = 0,
|
|
146
|
+
limit: int = 20,
|
|
147
|
+
) -> list[User]:
|
|
148
|
+
stmt = (
|
|
149
|
+
select(User)
|
|
150
|
+
.where(
|
|
151
|
+
User.email.ilike(f"%{query}%") |
|
|
152
|
+
User.name.ilike(f"%{query}%")
|
|
153
|
+
)
|
|
154
|
+
.offset(skip)
|
|
155
|
+
.limit(limit)
|
|
156
|
+
)
|
|
157
|
+
result = await self.session.execute(stmt)
|
|
158
|
+
return list(result.scalars().all())
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
## Query Patterns
|
|
162
|
+
|
|
163
|
+
### Select Queries
|
|
164
|
+
|
|
165
|
+
```python
|
|
166
|
+
from sqlalchemy import select, and_, or_, func
|
|
167
|
+
|
|
168
|
+
# Basic select
|
|
169
|
+
stmt = select(User).where(User.is_active == True)
|
|
170
|
+
|
|
171
|
+
# Multiple conditions
|
|
172
|
+
stmt = select(User).where(
|
|
173
|
+
and_(
|
|
174
|
+
User.is_active == True,
|
|
175
|
+
User.role == "admin",
|
|
176
|
+
)
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
# OR conditions
|
|
180
|
+
stmt = select(User).where(
|
|
181
|
+
or_(
|
|
182
|
+
User.email.contains("@company.com"),
|
|
183
|
+
User.role == "admin",
|
|
184
|
+
)
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
# Ordering
|
|
188
|
+
stmt = select(User).order_by(User.created_at.desc())
|
|
189
|
+
|
|
190
|
+
# Aggregation
|
|
191
|
+
stmt = select(func.count(User.id)).where(User.is_active == True)
|
|
192
|
+
result = await session.execute(stmt)
|
|
193
|
+
count = result.scalar_one()
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
### Joins and Relationships
|
|
197
|
+
|
|
198
|
+
```python
|
|
199
|
+
from sqlalchemy.orm import selectinload, joinedload
|
|
200
|
+
|
|
201
|
+
# Eager load relationships (N+1 prevention)
|
|
202
|
+
stmt = (
|
|
203
|
+
select(User)
|
|
204
|
+
.options(selectinload(User.posts))
|
|
205
|
+
.where(User.id == user_id)
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
# Join for filtering
|
|
209
|
+
stmt = (
|
|
210
|
+
select(User)
|
|
211
|
+
.join(User.posts)
|
|
212
|
+
.where(Post.published == True)
|
|
213
|
+
.distinct()
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
# Left outer join
|
|
217
|
+
stmt = (
|
|
218
|
+
select(User, func.count(Post.id).label("post_count"))
|
|
219
|
+
.outerjoin(User.posts)
|
|
220
|
+
.group_by(User.id)
|
|
221
|
+
)
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
### Pagination
|
|
225
|
+
|
|
226
|
+
```python
|
|
227
|
+
from sqlalchemy import select, func
|
|
228
|
+
|
|
229
|
+
async def get_paginated(
|
|
230
|
+
session: AsyncSession,
|
|
231
|
+
page: int = 1,
|
|
232
|
+
size: int = 20,
|
|
233
|
+
) -> tuple[list[User], int]:
|
|
234
|
+
# Count total
|
|
235
|
+
count_stmt = select(func.count(User.id))
|
|
236
|
+
total = await session.scalar(count_stmt)
|
|
237
|
+
|
|
238
|
+
# Get items
|
|
239
|
+
stmt = (
|
|
240
|
+
select(User)
|
|
241
|
+
.order_by(User.id)
|
|
242
|
+
.offset((page - 1) * size)
|
|
243
|
+
.limit(size)
|
|
244
|
+
)
|
|
245
|
+
result = await session.execute(stmt)
|
|
246
|
+
items = list(result.scalars().all())
|
|
247
|
+
|
|
248
|
+
return items, total
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
## Transactions
|
|
252
|
+
|
|
253
|
+
```python
|
|
254
|
+
from sqlalchemy.exc import IntegrityError
|
|
255
|
+
|
|
256
|
+
# Automatic transaction (recommended)
|
|
257
|
+
async with async_session_maker() as session:
|
|
258
|
+
user = User(email="test@example.com")
|
|
259
|
+
session.add(user)
|
|
260
|
+
await session.commit() # Commits transaction
|
|
261
|
+
|
|
262
|
+
# Manual transaction control
|
|
263
|
+
async with async_session_maker() as session:
|
|
264
|
+
async with session.begin():
|
|
265
|
+
user = User(email="test@example.com")
|
|
266
|
+
session.add(user)
|
|
267
|
+
# Commits automatically at end of block
|
|
268
|
+
|
|
269
|
+
# Nested transaction (savepoint)
|
|
270
|
+
async with async_session_maker() as session:
|
|
271
|
+
async with session.begin():
|
|
272
|
+
session.add(user1)
|
|
273
|
+
|
|
274
|
+
try:
|
|
275
|
+
async with session.begin_nested():
|
|
276
|
+
session.add(user2)
|
|
277
|
+
raise ValueError("Rollback nested only")
|
|
278
|
+
except ValueError:
|
|
279
|
+
pass # user2 rolled back, user1 still pending
|
|
280
|
+
|
|
281
|
+
await session.commit() # Only user1 committed
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
## Alembic Migrations
|
|
285
|
+
|
|
286
|
+
```bash
|
|
287
|
+
# Initialize
|
|
288
|
+
alembic init alembic
|
|
289
|
+
|
|
290
|
+
# Generate migration
|
|
291
|
+
alembic revision --autogenerate -m "Add users table"
|
|
292
|
+
|
|
293
|
+
# Apply migrations
|
|
294
|
+
alembic upgrade head
|
|
295
|
+
|
|
296
|
+
# Rollback
|
|
297
|
+
alembic downgrade -1
|
|
298
|
+
|
|
299
|
+
# Show current revision
|
|
300
|
+
alembic current
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
### Async Alembic Config
|
|
304
|
+
|
|
305
|
+
```python
|
|
306
|
+
# alembic/env.py
|
|
307
|
+
import asyncio
|
|
308
|
+
from sqlalchemy.ext.asyncio import create_async_engine
|
|
309
|
+
|
|
310
|
+
def run_migrations_online():
|
|
311
|
+
connectable = create_async_engine(settings.database_url)
|
|
312
|
+
|
|
313
|
+
async def do_run_migrations():
|
|
314
|
+
async with connectable.connect() as connection:
|
|
315
|
+
await connection.run_sync(do_run_migrations_sync)
|
|
316
|
+
await connectable.dispose()
|
|
317
|
+
|
|
318
|
+
def do_run_migrations_sync(connection):
|
|
319
|
+
context.configure(
|
|
320
|
+
connection=connection,
|
|
321
|
+
target_metadata=Base.metadata,
|
|
322
|
+
)
|
|
323
|
+
with context.begin_transaction():
|
|
324
|
+
context.run_migrations()
|
|
325
|
+
|
|
326
|
+
asyncio.run(do_run_migrations())
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
## Soft Delete
|
|
330
|
+
|
|
331
|
+
```python
|
|
332
|
+
from datetime import datetime
|
|
333
|
+
from sqlalchemy import event
|
|
334
|
+
|
|
335
|
+
class SoftDeleteMixin:
|
|
336
|
+
deleted_at: Mapped[datetime | None] = mapped_column(default=None)
|
|
337
|
+
|
|
338
|
+
@property
|
|
339
|
+
def is_deleted(self) -> bool:
|
|
340
|
+
return self.deleted_at is not None
|
|
341
|
+
|
|
342
|
+
def soft_delete(self) -> None:
|
|
343
|
+
self.deleted_at = datetime.utcnow()
|
|
344
|
+
|
|
345
|
+
class User(SoftDeleteMixin, Base):
|
|
346
|
+
__tablename__ = "users"
|
|
347
|
+
# ...
|
|
348
|
+
|
|
349
|
+
# Query filter for soft delete
|
|
350
|
+
def not_deleted():
|
|
351
|
+
return User.deleted_at.is_(None)
|
|
352
|
+
|
|
353
|
+
# Usage
|
|
354
|
+
stmt = select(User).where(not_deleted())
|
|
355
|
+
```
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
---
|
|
2
|
+
paths:
|
|
3
|
+
- "**/*.py"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# FastAPI Rules
|
|
7
|
+
|
|
8
|
+
## Router Structure
|
|
9
|
+
|
|
10
|
+
```python
|
|
11
|
+
from fastapi import APIRouter, Depends, status
|
|
12
|
+
from typing import Annotated
|
|
13
|
+
|
|
14
|
+
router = APIRouter()
|
|
15
|
+
|
|
16
|
+
@router.get(
|
|
17
|
+
"/",
|
|
18
|
+
response_model=list[UserResponse],
|
|
19
|
+
summary="List all users",
|
|
20
|
+
description="Get a paginated list of users",
|
|
21
|
+
)
|
|
22
|
+
async def list_users(
|
|
23
|
+
service: UserServiceDep,
|
|
24
|
+
skip: int = 0,
|
|
25
|
+
limit: int = 100,
|
|
26
|
+
) -> list[UserResponse]:
|
|
27
|
+
return await service.get_all(skip=skip, limit=limit)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@router.get(
|
|
31
|
+
"/{user_id}",
|
|
32
|
+
response_model=UserResponse,
|
|
33
|
+
responses={404: {"description": "User not found"}},
|
|
34
|
+
)
|
|
35
|
+
async def get_user(
|
|
36
|
+
user_id: int,
|
|
37
|
+
service: UserServiceDep,
|
|
38
|
+
) -> UserResponse:
|
|
39
|
+
return await service.get_by_id(user_id)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@router.post(
|
|
43
|
+
"/",
|
|
44
|
+
response_model=UserResponse,
|
|
45
|
+
status_code=status.HTTP_201_CREATED,
|
|
46
|
+
)
|
|
47
|
+
async def create_user(
|
|
48
|
+
user_data: UserCreate,
|
|
49
|
+
service: UserServiceDep,
|
|
50
|
+
) -> UserResponse:
|
|
51
|
+
return await service.create(user_data)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@router.put("/{user_id}", response_model=UserResponse)
|
|
55
|
+
async def update_user(
|
|
56
|
+
user_id: int,
|
|
57
|
+
user_data: UserUpdate,
|
|
58
|
+
service: UserServiceDep,
|
|
59
|
+
) -> UserResponse:
|
|
60
|
+
return await service.update(user_id, user_data)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
64
|
+
async def delete_user(
|
|
65
|
+
user_id: int,
|
|
66
|
+
service: UserServiceDep,
|
|
67
|
+
) -> None:
|
|
68
|
+
await service.delete(user_id)
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Pydantic Schemas (v2)
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
from pydantic import BaseModel, EmailStr, Field, ConfigDict
|
|
75
|
+
from datetime import datetime
|
|
76
|
+
|
|
77
|
+
# Base schema with common config
|
|
78
|
+
class BaseSchema(BaseModel):
|
|
79
|
+
model_config = ConfigDict(from_attributes=True)
|
|
80
|
+
|
|
81
|
+
# Request schemas
|
|
82
|
+
class UserCreate(BaseModel):
|
|
83
|
+
email: EmailStr
|
|
84
|
+
password: str = Field(min_length=8)
|
|
85
|
+
name: str = Field(min_length=1, max_length=100)
|
|
86
|
+
|
|
87
|
+
class UserUpdate(BaseModel):
|
|
88
|
+
email: EmailStr | None = None
|
|
89
|
+
name: str | None = Field(default=None, max_length=100)
|
|
90
|
+
|
|
91
|
+
# Response schemas
|
|
92
|
+
class UserResponse(BaseSchema):
|
|
93
|
+
id: int
|
|
94
|
+
email: EmailStr
|
|
95
|
+
name: str
|
|
96
|
+
created_at: datetime
|
|
97
|
+
|
|
98
|
+
# Nested schemas
|
|
99
|
+
class UserWithPostsResponse(UserResponse):
|
|
100
|
+
posts: list["PostResponse"] = []
|
|
101
|
+
|
|
102
|
+
# Pagination
|
|
103
|
+
class PaginatedResponse[T](BaseModel):
|
|
104
|
+
items: list[T]
|
|
105
|
+
total: int
|
|
106
|
+
page: int
|
|
107
|
+
size: int
|
|
108
|
+
pages: int
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
## Dependencies
|
|
112
|
+
|
|
113
|
+
### Validation with Dependencies
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
from fastapi import Depends, HTTPException, status, Path
|
|
117
|
+
|
|
118
|
+
async def valid_user_id(
|
|
119
|
+
user_id: Annotated[int, Path(gt=0)],
|
|
120
|
+
db: DbSession,
|
|
121
|
+
) -> User:
|
|
122
|
+
"""Validate user exists and return it."""
|
|
123
|
+
user = await db.get(User, user_id)
|
|
124
|
+
if not user:
|
|
125
|
+
raise HTTPException(
|
|
126
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
127
|
+
detail=f"User {user_id} not found",
|
|
128
|
+
)
|
|
129
|
+
return user
|
|
130
|
+
|
|
131
|
+
ValidUser = Annotated[User, Depends(valid_user_id)]
|
|
132
|
+
|
|
133
|
+
# Usage - user is already validated
|
|
134
|
+
@router.get("/{user_id}")
|
|
135
|
+
async def get_user(user: ValidUser) -> UserResponse:
|
|
136
|
+
return user
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
### Authentication Dependency
|
|
140
|
+
|
|
141
|
+
```python
|
|
142
|
+
from fastapi import Depends, HTTPException, status
|
|
143
|
+
from fastapi.security import OAuth2PasswordBearer
|
|
144
|
+
|
|
145
|
+
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
|
|
146
|
+
|
|
147
|
+
async def get_current_user(
|
|
148
|
+
token: Annotated[str, Depends(oauth2_scheme)],
|
|
149
|
+
db: DbSession,
|
|
150
|
+
) -> User:
|
|
151
|
+
credentials_exception = HTTPException(
|
|
152
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
153
|
+
detail="Could not validate credentials",
|
|
154
|
+
headers={"WWW-Authenticate": "Bearer"},
|
|
155
|
+
)
|
|
156
|
+
try:
|
|
157
|
+
payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm])
|
|
158
|
+
user_id: int = payload.get("sub")
|
|
159
|
+
if user_id is None:
|
|
160
|
+
raise credentials_exception
|
|
161
|
+
except JWTError:
|
|
162
|
+
raise credentials_exception
|
|
163
|
+
|
|
164
|
+
user = await db.get(User, user_id)
|
|
165
|
+
if user is None:
|
|
166
|
+
raise credentials_exception
|
|
167
|
+
return user
|
|
168
|
+
|
|
169
|
+
CurrentUser = Annotated[User, Depends(get_current_user)]
|
|
170
|
+
|
|
171
|
+
# Role-based dependency
|
|
172
|
+
def require_role(required_role: str):
|
|
173
|
+
async def check_role(user: CurrentUser) -> User:
|
|
174
|
+
if user.role != required_role:
|
|
175
|
+
raise HTTPException(
|
|
176
|
+
status_code=status.HTTP_403_FORBIDDEN,
|
|
177
|
+
detail="Insufficient permissions",
|
|
178
|
+
)
|
|
179
|
+
return user
|
|
180
|
+
return check_role
|
|
181
|
+
|
|
182
|
+
AdminUser = Annotated[User, Depends(require_role("admin"))]
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
## Background Tasks
|
|
186
|
+
|
|
187
|
+
```python
|
|
188
|
+
from fastapi import BackgroundTasks
|
|
189
|
+
|
|
190
|
+
async def send_welcome_email(email: str, name: str) -> None:
|
|
191
|
+
# Async email sending
|
|
192
|
+
...
|
|
193
|
+
|
|
194
|
+
@router.post("/", status_code=status.HTTP_201_CREATED)
|
|
195
|
+
async def create_user(
|
|
196
|
+
user_data: UserCreate,
|
|
197
|
+
background_tasks: BackgroundTasks,
|
|
198
|
+
service: UserServiceDep,
|
|
199
|
+
) -> UserResponse:
|
|
200
|
+
user = await service.create(user_data)
|
|
201
|
+
background_tasks.add_task(send_welcome_email, user.email, user.name)
|
|
202
|
+
return user
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
## Middleware
|
|
206
|
+
|
|
207
|
+
```python
|
|
208
|
+
from fastapi import FastAPI, Request
|
|
209
|
+
from starlette.middleware.cors import CORSMiddleware
|
|
210
|
+
import time
|
|
211
|
+
|
|
212
|
+
app = FastAPI()
|
|
213
|
+
|
|
214
|
+
# CORS
|
|
215
|
+
app.add_middleware(
|
|
216
|
+
CORSMiddleware,
|
|
217
|
+
allow_origins=["http://localhost:3000"],
|
|
218
|
+
allow_credentials=True,
|
|
219
|
+
allow_methods=["*"],
|
|
220
|
+
allow_headers=["*"],
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
# Custom middleware
|
|
224
|
+
@app.middleware("http")
|
|
225
|
+
async def add_process_time_header(request: Request, call_next):
|
|
226
|
+
start_time = time.perf_counter()
|
|
227
|
+
response = await call_next(request)
|
|
228
|
+
process_time = time.perf_counter() - start_time
|
|
229
|
+
response.headers["X-Process-Time"] = str(process_time)
|
|
230
|
+
return response
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
## Lifespan Events
|
|
234
|
+
|
|
235
|
+
```python
|
|
236
|
+
from contextlib import asynccontextmanager
|
|
237
|
+
from fastapi import FastAPI
|
|
238
|
+
|
|
239
|
+
@asynccontextmanager
|
|
240
|
+
async def lifespan(app: FastAPI):
|
|
241
|
+
# Startup
|
|
242
|
+
await init_db()
|
|
243
|
+
print("Application started")
|
|
244
|
+
|
|
245
|
+
yield
|
|
246
|
+
|
|
247
|
+
# Shutdown
|
|
248
|
+
await close_db()
|
|
249
|
+
print("Application stopped")
|
|
250
|
+
|
|
251
|
+
app = FastAPI(lifespan=lifespan)
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
## OpenAPI Documentation
|
|
255
|
+
|
|
256
|
+
```python
|
|
257
|
+
app = FastAPI(
|
|
258
|
+
title="My API",
|
|
259
|
+
description="API description with **markdown** support",
|
|
260
|
+
version="1.0.0",
|
|
261
|
+
docs_url="/docs",
|
|
262
|
+
redoc_url="/redoc",
|
|
263
|
+
openapi_tags=[
|
|
264
|
+
{"name": "users", "description": "User operations"},
|
|
265
|
+
{"name": "auth", "description": "Authentication"},
|
|
266
|
+
],
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
# Disable docs in production
|
|
270
|
+
if settings.environment == "production":
|
|
271
|
+
app = FastAPI(docs_url=None, redoc_url=None)
|
|
272
|
+
```
|