@pilllesss/yorn 1.0.182 → 1.0.183
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.
Potentially problematic release.
This version of @pilllesss/yorn might be problematic. Click here for more details.
- package/README.md +1 -1
- package/dist/providers/data/.manifest.json +1 -1
- package/dist/skills/code-review/LICENSE +21 -0
- package/dist/skills/code-review/SKILL.md +233 -0
- package/dist/skills/code-review/assets/pr-review-template.md +137 -0
- package/dist/skills/code-review/assets/review-checklist.md +123 -0
- package/dist/skills/code-review/reference/angular.md +768 -0
- package/dist/skills/code-review/reference/architecture-review-guide.md +472 -0
- package/dist/skills/code-review/reference/c.md +890 -0
- package/dist/skills/code-review/reference/code-quality-universal.md +488 -0
- package/dist/skills/code-review/reference/code-review-best-practices.md +136 -0
- package/dist/skills/code-review/reference/common-bugs-checklist.md +302 -0
- package/dist/skills/code-review/reference/cpp.md +893 -0
- package/dist/skills/code-review/reference/cross-cutting/async-concurrency-patterns.md +515 -0
- package/dist/skills/code-review/reference/cross-cutting/error-handling-principles.md +492 -0
- package/dist/skills/code-review/reference/cross-cutting/n-plus-one-queries.md +309 -0
- package/dist/skills/code-review/reference/cross-cutting/sql-injection-prevention.md +308 -0
- package/dist/skills/code-review/reference/cross-cutting/xss-prevention.md +264 -0
- package/dist/skills/code-review/reference/csharp.md +525 -0
- package/dist/skills/code-review/reference/css-less-sass.md +661 -0
- package/dist/skills/code-review/reference/dart.md +670 -0
- package/dist/skills/code-review/reference/django.md +985 -0
- package/dist/skills/code-review/reference/fastapi.md +580 -0
- package/dist/skills/code-review/reference/go.md +993 -0
- package/dist/skills/code-review/reference/java.md +409 -0
- package/dist/skills/code-review/reference/java8.md +586 -0
- package/dist/skills/code-review/reference/kotlin.md +1018 -0
- package/dist/skills/code-review/reference/nestjs.md +593 -0
- package/dist/skills/code-review/reference/performance-review-guide.md +816 -0
- package/dist/skills/code-review/reference/php.md +684 -0
- package/dist/skills/code-review/reference/python.md +1073 -0
- package/dist/skills/code-review/reference/qt.md +757 -0
- package/dist/skills/code-review/reference/react.md +871 -0
- package/dist/skills/code-review/reference/ruby.md +964 -0
- package/dist/skills/code-review/reference/rust.md +846 -0
- package/dist/skills/code-review/reference/security-review-guide.md +494 -0
- package/dist/skills/code-review/reference/svelte.md +1064 -0
- package/dist/skills/code-review/reference/swift.md +936 -0
- package/dist/skills/code-review/reference/typescript.md +1016 -0
- package/dist/skills/code-review/reference/vue.md +924 -0
- package/dist/skills/code-review/reference/zig.md +440 -0
- package/dist/skills/code-review/scripts/pr-analyzer.py +435 -0
- package/dist/skills/code-review/scripts/test_pr_analyzer.py +380 -0
- package/dist/yorn.cjs +628 -628
- package/package.json +2 -2
|
@@ -0,0 +1,580 @@
|
|
|
1
|
+
# FastAPI Code Review Guide
|
|
2
|
+
|
|
3
|
+
> FastAPI code review guide covering dependency injection (`Depends`), Pydantic v2 validation boundaries, async correctness, database session lifecycle and N+1, security, and a test-driven verification workflow that turns the reviewer's in-process test client into a tool for *proving* bugs rather than guessing at them.
|
|
4
|
+
|
|
5
|
+
## Table of Contents
|
|
6
|
+
|
|
7
|
+
- [Dependency Injection (`Depends`)](#dependency-injection-depends)
|
|
8
|
+
- [Pydantic v2 Models & Validation](#pydantic-v2-models--validation)
|
|
9
|
+
- [Async Correctness](#async-correctness)
|
|
10
|
+
- [Database Sessions & N+1](#database-sessions--n1)
|
|
11
|
+
- [Security](#security)
|
|
12
|
+
- [Test-Driven Verification](#test-driven-verification)
|
|
13
|
+
- [Review Checklist](#review-checklist)
|
|
14
|
+
- [References](#references)
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## Dependency Injection (`Depends`)
|
|
19
|
+
|
|
20
|
+
FastAPI's `Depends` is the seam that keeps routes thin and testable. Most review problems here come from doing real work in the route function instead of behind a dependency.
|
|
21
|
+
|
|
22
|
+
### Business logic belongs behind a dependency or service, not in the route
|
|
23
|
+
|
|
24
|
+
```python
|
|
25
|
+
# ❌ Bad — DB access, auth, and business rules all inline in the route
|
|
26
|
+
@app.get("/orders/{order_id}")
|
|
27
|
+
async def get_order(order_id: int):
|
|
28
|
+
conn = await asyncpg.connect(DATABASE_URL) # connection created per request
|
|
29
|
+
row = await conn.fetchrow("SELECT * FROM orders WHERE id = $1", order_id)
|
|
30
|
+
await conn.close()
|
|
31
|
+
if row is None:
|
|
32
|
+
raise HTTPException(404)
|
|
33
|
+
return dict(row)
|
|
34
|
+
|
|
35
|
+
# ✅ Good — the route declares what it needs; the session is injected and pooled
|
|
36
|
+
async def get_session() -> AsyncIterator[AsyncSession]:
|
|
37
|
+
async with SessionLocal() as session:
|
|
38
|
+
yield session
|
|
39
|
+
|
|
40
|
+
@app.get("/orders/{order_id}", response_model=OrderOut)
|
|
41
|
+
async def get_order(order_id: int, session: AsyncSession = Depends(get_session)):
|
|
42
|
+
order = await session.get(Order, order_id)
|
|
43
|
+
if order is None:
|
|
44
|
+
raise HTTPException(status_code=404, detail="Order not found")
|
|
45
|
+
return order
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
The injected version is also the version you can override in tests (see [Test-Driven Verification](#test-driven-verification)).
|
|
49
|
+
|
|
50
|
+
### `yield` dependencies must clean up, and cleanup runs even on error
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
# ❌ Bad — no cleanup; the session leaks if the route raises
|
|
54
|
+
async def get_session() -> AsyncSession:
|
|
55
|
+
return SessionLocal()
|
|
56
|
+
|
|
57
|
+
# ✅ Good — the context manager closes the session on success AND on exception
|
|
58
|
+
async def get_session() -> AsyncIterator[AsyncSession]:
|
|
59
|
+
async with SessionLocal() as session:
|
|
60
|
+
yield session
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Review point: confirm any `yield` dependency holding a resource (DB session, file handle, lock) releases it through a context manager or `try/finally`, so an exception in the route does not leak it.
|
|
64
|
+
|
|
65
|
+
### Don't re-create singletons per request
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
# ❌ Bad — a new HTTP client (and connection pool) per request
|
|
69
|
+
@app.get("/proxy")
|
|
70
|
+
async def proxy(client: httpx.AsyncClient = Depends(lambda: httpx.AsyncClient())):
|
|
71
|
+
...
|
|
72
|
+
|
|
73
|
+
# ✅ Good — one client for the app lifetime, injected by reference
|
|
74
|
+
@asynccontextmanager
|
|
75
|
+
async def lifespan(app: FastAPI):
|
|
76
|
+
app.state.http = httpx.AsyncClient()
|
|
77
|
+
yield
|
|
78
|
+
await app.state.http.aclose()
|
|
79
|
+
|
|
80
|
+
def get_http(request: Request) -> httpx.AsyncClient:
|
|
81
|
+
return request.app.state.http
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### Prefer the `Annotated` form and async dependencies
|
|
85
|
+
|
|
86
|
+
Since FastAPI 0.95 the idiomatic way to declare a dependency is `Annotated[T, Depends(...)]`, not the default-value form. It is reusable across routes and plays well with type checkers. Also prefer `async def` dependencies: a sync (`def`) dependency runs in the threadpool, which is wasted overhead for a small non-I/O check.
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
# ⚠️ Older form — still works, but not the current idiom
|
|
90
|
+
@app.get("/items")
|
|
91
|
+
async def list_items(session: AsyncSession = Depends(get_session)): ...
|
|
92
|
+
|
|
93
|
+
# ✅ Good — Annotated form; define once, reuse everywhere
|
|
94
|
+
SessionDep = Annotated[AsyncSession, Depends(get_session)]
|
|
95
|
+
|
|
96
|
+
@app.get("/items")
|
|
97
|
+
async def list_items(session: SessionDep): ...
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
### Use dependencies to validate existence and permissions — they're cached per request
|
|
101
|
+
|
|
102
|
+
A dependency is the natural place to answer "does this resource exist and may this caller touch it?" Pydantic validates *shape*; a dependency validates against the database. FastAPI caches each dependency's result within a single request, so chaining small dependencies costs nothing extra and removes duplicated lookups.
|
|
103
|
+
|
|
104
|
+
```python
|
|
105
|
+
# ✅ Good — small dependencies chain; valid_post is resolved once per request
|
|
106
|
+
async def valid_post(post_id: int, session: SessionDep) -> Post:
|
|
107
|
+
post = await session.get(Post, post_id)
|
|
108
|
+
if post is None:
|
|
109
|
+
raise HTTPException(status_code=404, detail="Post not found")
|
|
110
|
+
return post
|
|
111
|
+
|
|
112
|
+
async def owned_post(post: Annotated[Post, Depends(valid_post)], user: CurrentUser) -> Post:
|
|
113
|
+
if post.owner_id != user.id:
|
|
114
|
+
raise HTTPException(status_code=403, detail="Forbidden")
|
|
115
|
+
return post
|
|
116
|
+
|
|
117
|
+
@app.delete("/posts/{post_id}", status_code=204)
|
|
118
|
+
async def delete_post(post: Annotated[Post, Depends(owned_post)], session: SessionDep):
|
|
119
|
+
await session.delete(post) # existence + ownership already enforced
|
|
120
|
+
await session.commit()
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
This is also the cleanest place to fix the auth-vs-authorization bug from the [Security](#security) section: the ownership check moves into a reusable `owned_post` dependency.
|
|
124
|
+
|
|
125
|
+
---
|
|
126
|
+
|
|
127
|
+
## Pydantic v2 Models & Validation
|
|
128
|
+
|
|
129
|
+
### Separate input and output models; never echo the ORM object directly
|
|
130
|
+
|
|
131
|
+
```python
|
|
132
|
+
# ❌ Bad — response_model is the DB model, so hashed_password leaks to the client
|
|
133
|
+
@app.post("/users", response_model=UserTable)
|
|
134
|
+
async def create_user(user: UserTable): # also accepts client-set id, is_admin...
|
|
135
|
+
...
|
|
136
|
+
|
|
137
|
+
# ✅ Good — distinct schemas draw the trust boundary
|
|
138
|
+
class UserCreate(BaseModel):
|
|
139
|
+
email: EmailStr
|
|
140
|
+
password: str
|
|
141
|
+
|
|
142
|
+
class UserOut(BaseModel):
|
|
143
|
+
id: int
|
|
144
|
+
email: EmailStr
|
|
145
|
+
model_config = ConfigDict(from_attributes=True) # read from ORM safely
|
|
146
|
+
|
|
147
|
+
@app.post("/users", response_model=UserOut, status_code=201)
|
|
148
|
+
async def create_user(payload: UserCreate, session: AsyncSession = Depends(get_session)):
|
|
149
|
+
...
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
`response_model` is a filter, not just documentation — fields absent from the output model are stripped from the response. Reusing the DB model as the response is the most common way sensitive fields leak.
|
|
153
|
+
|
|
154
|
+
### Use distinct Create and Update schemas
|
|
155
|
+
|
|
156
|
+
```python
|
|
157
|
+
# ❌ Bad — one schema for create and update means every field is required on PATCH
|
|
158
|
+
class ItemSchema(BaseModel):
|
|
159
|
+
name: str
|
|
160
|
+
price: float
|
|
161
|
+
|
|
162
|
+
# ✅ Good — update is a partial; create requires the full payload
|
|
163
|
+
class ItemCreate(BaseModel):
|
|
164
|
+
name: str
|
|
165
|
+
price: float = Field(gt=0)
|
|
166
|
+
|
|
167
|
+
class ItemUpdate(BaseModel):
|
|
168
|
+
name: str | None = None
|
|
169
|
+
price: float | None = Field(default=None, gt=0)
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
### Validate at the boundary, not after the DB write
|
|
173
|
+
|
|
174
|
+
```python
|
|
175
|
+
# ❌ Bad — negative quantity reaches the database before anything checks it
|
|
176
|
+
@app.post("/cart")
|
|
177
|
+
async def add_to_cart(item_id: int, quantity: int):
|
|
178
|
+
await save(item_id, quantity) # quantity = -5 silently accepted
|
|
179
|
+
|
|
180
|
+
# ✅ Good — the type system rejects it before the handler body runs
|
|
181
|
+
class CartLine(BaseModel):
|
|
182
|
+
item_id: int
|
|
183
|
+
quantity: int = Field(gt=0)
|
|
184
|
+
|
|
185
|
+
@app.post("/cart")
|
|
186
|
+
async def add_to_cart(line: CartLine):
|
|
187
|
+
await save(line.item_id, line.quantity)
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
---
|
|
191
|
+
|
|
192
|
+
## Async Correctness
|
|
193
|
+
|
|
194
|
+
This is the axis on which FastAPI differs most from Django and Flask, and the one most worth a reviewer's attention. FastAPI's throughput comes from a single event loop interleaving many concurrent requests. That model only holds if the loop is **never blocked**: one synchronous call on the loop stalls *every* in-flight request, not just its own. Get this wrong across the codebase and FastAPI does not just lose its edge — it performs *worse* than a sync framework like Flask, because Flask's worker-per-request model has no shared loop to choke. The reviewer's job is to keep work on the loop genuinely non-blocking and to treat every escape hatch as a cost, not a fix.
|
|
195
|
+
|
|
196
|
+
### Never call blocking code inside an `async def` route
|
|
197
|
+
|
|
198
|
+
```python
|
|
199
|
+
# ❌ Bad — blocking I/O on the loop freezes ALL concurrent requests, not just this one
|
|
200
|
+
@app.get("/report")
|
|
201
|
+
async def report():
|
|
202
|
+
data = requests.get("https://slow-api.example.com").json() # blocking socket
|
|
203
|
+
time.sleep(2) # blocks the loop
|
|
204
|
+
return data
|
|
205
|
+
|
|
206
|
+
# ✅ Good — await a native-async client; the loop serves other requests meanwhile
|
|
207
|
+
@app.get("/report")
|
|
208
|
+
async def report(client: httpx.AsyncClient = Depends(get_http)):
|
|
209
|
+
resp = await client.get("https://slow-api.example.com")
|
|
210
|
+
return resp.json()
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
### Prefer native-async SDKs over sync libraries
|
|
214
|
+
|
|
215
|
+
The right fix for blocking I/O is almost always a library that speaks `async` natively — not wrapping a sync one. Reach for the async client first; the threadpool is the last resort, not the default.
|
|
216
|
+
|
|
217
|
+
| Sync (blocks the loop) | Native-async replacement |
|
|
218
|
+
|------------------------|--------------------------|
|
|
219
|
+
| `requests` | `httpx.AsyncClient`, `aiohttp` |
|
|
220
|
+
| `psycopg2` (sync) | `asyncpg`, SQLAlchemy async engine |
|
|
221
|
+
| `redis-py` (sync) | `redis.asyncio` |
|
|
222
|
+
| `pymongo` | `motor` |
|
|
223
|
+
| `boto3` | `aioboto3` |
|
|
224
|
+
|
|
225
|
+
If you find `asyncio.run(...)`, a new event loop, or a manually started thread *inside* a route, that is a red flag — it's an attempt to bolt sync code onto the loop. `asyncio.run()` inside a running loop raises `RuntimeError` outright; the rest quietly burns the performance you adopted FastAPI for.
|
|
226
|
+
|
|
227
|
+
```python
|
|
228
|
+
# ❌ Bad — spinning up a loop/thread to call an async SDK from a sync context
|
|
229
|
+
@app.get("/users/{uid}")
|
|
230
|
+
def get_user(uid: int):
|
|
231
|
+
return asyncio.run(repo.fetch(uid)) # RuntimeError under the running loop
|
|
232
|
+
|
|
233
|
+
# ✅ Good — let the route be async and await the native client directly
|
|
234
|
+
@app.get("/users/{uid}")
|
|
235
|
+
async def get_user(uid: int):
|
|
236
|
+
return await repo.fetch(uid)
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
### The threadpool is a bounded escape hatch, not a default
|
|
240
|
+
|
|
241
|
+
A plain `def` route — and `run_in_threadpool(...)` — does not run on the loop; FastAPI runs it in a **bounded** worker threadpool (AnyIO's default cap is 40 threads). For an occasional, genuinely-unavoidable blocking call this is the correct tool:
|
|
242
|
+
|
|
243
|
+
```python
|
|
244
|
+
from fastapi.concurrency import run_in_threadpool
|
|
245
|
+
|
|
246
|
+
@app.get("/legacy")
|
|
247
|
+
async def legacy():
|
|
248
|
+
return await run_in_threadpool(blocking_library_call) # only if no async SDK exists
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
But it does not scale the way the loop does. Route every hot path through the threadpool and, under load, all workers block at once; further requests queue behind the cap and throughput collapses. Spawning your own threads or processes to "add concurrency" makes it worse: once live threads exceed the machine's core count, context-switch and GIL contention degrade performance sharply rather than improving it. The escape hatch is for the rare blocking dependency you cannot replace — not a substitute for choosing async SDKs.
|
|
252
|
+
|
|
253
|
+
Review heuristic: a `def` route is acceptable for a low-traffic endpoint with no async equivalent. A high-traffic endpoint doing blocking work in a `def` route (or via `run_in_threadpool`) is a scaling bug — flag it and ask for an async SDK.
|
|
254
|
+
|
|
255
|
+
### CPU-bound work belongs in a worker process, not the loop or the threadpool
|
|
256
|
+
|
|
257
|
+
Neither the event loop nor the threadpool helps CPU-bound work: under the GIL only one thread runs Python bytecode at a time, so a heavy computation blocks just as badly from a threadpool as from the loop. Offload it to a separate process (Celery, Arq, RQ, or `multiprocessing`).
|
|
258
|
+
|
|
259
|
+
```python
|
|
260
|
+
# ❌ Bad — a CPU-heavy job pins a worker; throughput drops for everyone
|
|
261
|
+
@app.post("/render")
|
|
262
|
+
async def render(doc: Doc):
|
|
263
|
+
return heavy_pdf_render(doc) # seconds of pure CPU on the loop
|
|
264
|
+
|
|
265
|
+
# ✅ Good — enqueue to a worker process; return a job handle
|
|
266
|
+
@app.post("/render", status_code=202)
|
|
267
|
+
async def render(doc: Doc):
|
|
268
|
+
job = await queue.enqueue(heavy_pdf_render, doc)
|
|
269
|
+
return {"job_id": job.id}
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
### Don't fire-and-forget unawaited coroutines
|
|
273
|
+
|
|
274
|
+
```python
|
|
275
|
+
# ❌ Bad — coroutine never awaited; the email is never sent (and no error surfaces)
|
|
276
|
+
@app.post("/signup")
|
|
277
|
+
async def signup(user: UserCreate):
|
|
278
|
+
send_welcome_email(user.email) # returns a coroutine, silently dropped
|
|
279
|
+
|
|
280
|
+
# ✅ Good — defer post-response work with BackgroundTasks
|
|
281
|
+
@app.post("/signup")
|
|
282
|
+
async def signup(user: UserCreate, tasks: BackgroundTasks):
|
|
283
|
+
tasks.add_task(send_welcome_email, user.email)
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
`BackgroundTasks` runs in-process and offers no retries or persistence — use it only for short, fire-and-forget work (send an email, log an event). Anything long-running or retry-critical (data processing, payments) belongs in a real task queue (Celery/Arq/RQ).
|
|
287
|
+
|
|
288
|
+
---
|
|
289
|
+
|
|
290
|
+
## Database Sessions & N+1
|
|
291
|
+
|
|
292
|
+
> 📖 For cross-language N+1 patterns and solutions, see [N+1 Queries Guide](cross-cutting/n-plus-one-queries.md)
|
|
293
|
+
|
|
294
|
+
### One session per request, injected — not a global
|
|
295
|
+
|
|
296
|
+
```python
|
|
297
|
+
# ❌ Bad — a module-level session is shared across concurrent requests (not safe)
|
|
298
|
+
session = SessionLocal()
|
|
299
|
+
|
|
300
|
+
# ✅ Good — request-scoped session via dependency (see get_session above)
|
|
301
|
+
@app.get("/items")
|
|
302
|
+
async def list_items(session: AsyncSession = Depends(get_session)):
|
|
303
|
+
...
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
### Eager-load relationships to avoid N+1
|
|
307
|
+
|
|
308
|
+
```python
|
|
309
|
+
# ❌ Bad — one query for orders, then one query per order for its customer
|
|
310
|
+
orders = (await session.execute(select(Order))).scalars().all()
|
|
311
|
+
return [{"id": o.id, "customer": o.customer.name} for o in orders] # N+1
|
|
312
|
+
|
|
313
|
+
# ✅ Good — a single query with the relationship eager-loaded
|
|
314
|
+
stmt = select(Order).options(selectinload(Order.customer))
|
|
315
|
+
orders = (await session.execute(stmt)).scalars().all()
|
|
316
|
+
return [{"id": o.id, "customer": o.customer.name} for o in orders]
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
With async SQLAlchemy, lazy attribute access outside the session often raises instead of silently querying — but the design issue is the same. Look for relationship access inside a loop without an `options(...)` eager load.
|
|
320
|
+
|
|
321
|
+
### Paginate list endpoints
|
|
322
|
+
|
|
323
|
+
```python
|
|
324
|
+
# ❌ Bad — returns every row; degrades as the table grows
|
|
325
|
+
@app.get("/users")
|
|
326
|
+
async def list_users(session: AsyncSession = Depends(get_session)):
|
|
327
|
+
return (await session.execute(select(User))).scalars().all()
|
|
328
|
+
|
|
329
|
+
# ✅ Good — bounded page with a sane cap
|
|
330
|
+
@app.get("/users", response_model=list[UserOut])
|
|
331
|
+
async def list_users(
|
|
332
|
+
session: AsyncSession = Depends(get_session),
|
|
333
|
+
limit: int = Query(default=50, le=100),
|
|
334
|
+
offset: int = Query(default=0, ge=0),
|
|
335
|
+
):
|
|
336
|
+
stmt = select(User).limit(limit).offset(offset)
|
|
337
|
+
return (await session.execute(stmt)).scalars().all()
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
### Aggregate and join in SQL, not in Python
|
|
341
|
+
|
|
342
|
+
If a handler pulls rows into memory and then loops to group, count, or join them, the database is being used as dumb storage. Push the work down — the database does set operations far faster, and you transfer less data.
|
|
343
|
+
|
|
344
|
+
```python
|
|
345
|
+
# ❌ Bad — fetch every order, then tally per customer in Python
|
|
346
|
+
orders = (await session.execute(select(Order))).scalars().all()
|
|
347
|
+
totals: dict[int, float] = {}
|
|
348
|
+
for o in orders:
|
|
349
|
+
totals[o.customer_id] = totals.get(o.customer_id, 0) + o.amount
|
|
350
|
+
|
|
351
|
+
# ✅ Good — let the database group and sum
|
|
352
|
+
stmt = select(Order.customer_id, func.sum(Order.amount)).group_by(Order.customer_id)
|
|
353
|
+
totals = dict((await session.execute(stmt)).all())
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
---
|
|
357
|
+
|
|
358
|
+
## Security
|
|
359
|
+
|
|
360
|
+
### A declared auth dependency is not an enforced authorization check
|
|
361
|
+
|
|
362
|
+
This is the highest-value thing to look for. `Depends(get_current_user)` proves *who* the caller is — it does **not** prove they may touch *this* resource.
|
|
363
|
+
|
|
364
|
+
```python
|
|
365
|
+
# ❌ Bad — any authenticated user can delete any other user's document
|
|
366
|
+
@app.delete("/documents/{doc_id}")
|
|
367
|
+
async def delete_document(
|
|
368
|
+
doc_id: int,
|
|
369
|
+
user: User = Depends(get_current_user),
|
|
370
|
+
session: AsyncSession = Depends(get_session),
|
|
371
|
+
):
|
|
372
|
+
doc = await session.get(Document, doc_id)
|
|
373
|
+
await session.delete(doc) # never checks doc.owner_id == user.id
|
|
374
|
+
await session.commit()
|
|
375
|
+
|
|
376
|
+
# ✅ Good — ownership is verified before the mutation
|
|
377
|
+
@app.delete("/documents/{doc_id}", status_code=204)
|
|
378
|
+
async def delete_document(
|
|
379
|
+
doc_id: int,
|
|
380
|
+
user: User = Depends(get_current_user),
|
|
381
|
+
session: AsyncSession = Depends(get_session),
|
|
382
|
+
):
|
|
383
|
+
doc = await session.get(Document, doc_id)
|
|
384
|
+
if doc is None:
|
|
385
|
+
raise HTTPException(status_code=404, detail="Not found")
|
|
386
|
+
if doc.owner_id != user.id:
|
|
387
|
+
raise HTTPException(status_code=403, detail="Forbidden")
|
|
388
|
+
await session.delete(doc)
|
|
389
|
+
await session.commit()
|
|
390
|
+
```
|
|
391
|
+
|
|
392
|
+
The [Test-Driven Verification](#test-driven-verification) section reproduces exactly this bug with a failing test.
|
|
393
|
+
|
|
394
|
+
### Parameterize SQL; never f-string user input
|
|
395
|
+
|
|
396
|
+
> **跨语言 SQL 注入防护详见 [SQL Injection Prevention Guide](cross-cutting/sql-injection-prevention.md)**,含 Python/Java/Go/Node.js/PHP/C# 示例及 ORM 不安全用法。
|
|
397
|
+
|
|
398
|
+
### Don't widen CORS to credentials + wildcard
|
|
399
|
+
|
|
400
|
+
```python
|
|
401
|
+
# ❌ Bad — wildcard origin together with credentials is rejected by browsers and unsafe
|
|
402
|
+
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_credentials=True)
|
|
403
|
+
|
|
404
|
+
# ✅ Good — enumerate trusted origins when credentials are allowed
|
|
405
|
+
app.add_middleware(
|
|
406
|
+
CORSMiddleware,
|
|
407
|
+
allow_origins=["https://app.example.com"],
|
|
408
|
+
allow_credentials=True,
|
|
409
|
+
)
|
|
410
|
+
```
|
|
411
|
+
|
|
412
|
+
Also check: secrets read from config/env (not hard-coded), `HTTPException` details that don't leak internals (stack traces, SQL), and rate limiting on auth endpoints.
|
|
413
|
+
|
|
414
|
+
---
|
|
415
|
+
|
|
416
|
+
## Test-Driven Verification
|
|
417
|
+
|
|
418
|
+
> Inspired by the test-driven development discipline: *if you didn't watch the test fail, you don't know it tests the right thing.* This matters even more for a coding agent than for a human reviewer. An agent's reading and reasoning are fallible — it can misread control flow, hallucinate a guarantee that isn't there, or rationalize a comfortable conclusion — so a prose verdict like "this looks safe" carries little weight on its own. An executable test is the one piece of **objective ground truth** the agent fully controls: it either passes or it doesn't, regardless of how confident the reasoning felt. That is what makes tests the agent's anchor of confidence. Reviewing the same way the discipline writes code — reproduce, don't assert — turns a hunch into proof.
|
|
419
|
+
|
|
420
|
+
A natural-language review comment ("this might let users delete each other's data") is exactly that kind of fallible hypothesis. FastAPI makes the ground truth cheap to obtain: an in-process client (`httpx.AsyncClient` over `ASGITransport`) runs the whole app, and `app.dependency_overrides` swaps out auth and the database without patching internals. So instead of trusting its own read of the code, the agent settles the question by reproduction.
|
|
421
|
+
|
|
422
|
+
### Reproduce a suspected bug with a failing test (Verify RED)
|
|
423
|
+
|
|
424
|
+
Suppose the reviewer suspects the `DELETE /documents/{doc_id}` route above never checks ownership. Write the test that asserts the *secure* behavior, then run it and **watch it fail** — the failure is the proof.
|
|
425
|
+
|
|
426
|
+
```python
|
|
427
|
+
# test_document_authorization.py
|
|
428
|
+
import pytest
|
|
429
|
+
from httpx import AsyncClient, ASGITransport
|
|
430
|
+
from fastapi import Header
|
|
431
|
+
from app.main import app
|
|
432
|
+
from app.deps import get_current_user, get_session
|
|
433
|
+
|
|
434
|
+
# Two users; the override picks one based on a test header.
|
|
435
|
+
USERS = {"alice": User(id=1, email="alice@example.com"),
|
|
436
|
+
"bob": User(id=2, email="bob@example.com")}
|
|
437
|
+
|
|
438
|
+
def fake_current_user(x_test_user: str = Header(default="alice")) -> User:
|
|
439
|
+
return USERS[x_test_user]
|
|
440
|
+
|
|
441
|
+
@pytest.mark.asyncio
|
|
442
|
+
async def test_user_cannot_delete_another_users_document(session): # async fixture
|
|
443
|
+
# Arrange: a document owned by Alice (id=1)
|
|
444
|
+
session.add(Document(id=10, owner_id=1, title="Alice's doc"))
|
|
445
|
+
await session.commit()
|
|
446
|
+
|
|
447
|
+
app.dependency_overrides[get_current_user] = fake_current_user
|
|
448
|
+
app.dependency_overrides[get_session] = lambda: session
|
|
449
|
+
|
|
450
|
+
# Act: Bob tries to delete Alice's document
|
|
451
|
+
transport = ASGITransport(app=app)
|
|
452
|
+
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
|
453
|
+
resp = await client.delete("/documents/10", headers={"X-Test-User": "bob"})
|
|
454
|
+
|
|
455
|
+
# Assert the SECURE behavior we expect
|
|
456
|
+
assert resp.status_code == 403
|
|
457
|
+
|
|
458
|
+
app.dependency_overrides.clear()
|
|
459
|
+
```
|
|
460
|
+
|
|
461
|
+
Run it against the unfixed code and confirm the failure is the bug, not a typo:
|
|
462
|
+
|
|
463
|
+
```bash
|
|
464
|
+
$ pytest test_document_authorization.py
|
|
465
|
+
FAILED assert 204 == 403
|
|
466
|
+
# ^ the endpoint deleted Alice's document for Bob — vulnerability confirmed
|
|
467
|
+
```
|
|
468
|
+
|
|
469
|
+
A failure of `204 == 403` (not an import error, not a 404) is what makes the finding credible: the route returned success for an action that should have been forbidden. Now the fix from the [Security](#security) section turns it green:
|
|
470
|
+
|
|
471
|
+
```bash
|
|
472
|
+
$ pytest test_document_authorization.py
|
|
473
|
+
PASSED
|
|
474
|
+
```
|
|
475
|
+
|
|
476
|
+
Attach this test to the review. It documents the vulnerability, proves the fix, and guards against regression — far stronger than "consider checking ownership here."
|
|
477
|
+
|
|
478
|
+
### Prefer `dependency_overrides` over `patch`/`mock`
|
|
479
|
+
|
|
480
|
+
FastAPI's DI is the seam the TDD discipline asks for: when something is hard to test without mocking everything, that usually signals coupling — and `Depends` already gives you the injection point, so you rarely need `unittest.mock.patch`.
|
|
481
|
+
|
|
482
|
+
```python
|
|
483
|
+
# ❌ Bad — patching internals: brittle, couples the test to import paths
|
|
484
|
+
@patch("app.routes.orders.asyncpg.connect")
|
|
485
|
+
def test_get_order(mock_connect): ...
|
|
486
|
+
|
|
487
|
+
# ✅ Good — override the dependency with a real in-memory fake
|
|
488
|
+
app.dependency_overrides[get_session] = lambda: in_memory_session
|
|
489
|
+
app.dependency_overrides[get_current_user] = lambda: test_user
|
|
490
|
+
```
|
|
491
|
+
|
|
492
|
+
Always reset overrides between tests (`app.dependency_overrides.clear()` in a fixture teardown) so state doesn't leak across tests.
|
|
493
|
+
|
|
494
|
+
The reproduction above uses `httpx.AsyncClient` over `ASGITransport` with `@pytest.mark.asyncio` — the community convention for an async app, so the suite shares the app's event loop and you avoid loop-mismatch errors later. The synchronous `TestClient` is simpler and fine for a fully sync app, but standardizing on the async client from the start saves a painful migration once any route or fixture becomes async.
|
|
495
|
+
|
|
496
|
+
### Critique the PR's own tests, not just its source
|
|
497
|
+
|
|
498
|
+
A PR that ships tests is not automatically safe. Apply these checks to the *tests* in the diff:
|
|
499
|
+
|
|
500
|
+
```python
|
|
501
|
+
# ❌ Bad — happy-path only. Proves the route works when everything is correct,
|
|
502
|
+
# says nothing about the validation and authorization paths.
|
|
503
|
+
def test_create_item():
|
|
504
|
+
resp = client.post("/items", json={"name": "x", "price": 5})
|
|
505
|
+
assert resp.status_code == 201
|
|
506
|
+
|
|
507
|
+
# ✅ Good — the boundary and failure paths are where bugs live
|
|
508
|
+
def test_create_item_rejects_negative_price():
|
|
509
|
+
resp = client.post("/items", json={"name": "x", "price": -5})
|
|
510
|
+
assert resp.status_code == 422
|
|
511
|
+
|
|
512
|
+
def test_create_item_requires_authentication():
|
|
513
|
+
resp = client_without_auth.post("/items", json={"name": "x", "price": 5})
|
|
514
|
+
assert resp.status_code == 401
|
|
515
|
+
```
|
|
516
|
+
|
|
517
|
+
Review questions for the test suite:
|
|
518
|
+
|
|
519
|
+
- **Does it test behavior, or the mock?** An assertion that only confirms a mock was called proves the test's own setup, not the endpoint.
|
|
520
|
+
- **Are the failure paths covered?** 401/403/404/422 — not just 200/201. Bugs cluster at the boundaries.
|
|
521
|
+
- **Is the mock complete?** A partial mock of an external API response that omits fields the handler reads passes in the test and fails in production.
|
|
522
|
+
- **Were the tests written after the fact?** Tests added alongside an implementation and passing on the first run never demonstrated that they can fail — and so prove little. A test that reproduces the bug (fails first, then passes) is worth more than one that was green from birth.
|
|
523
|
+
|
|
524
|
+
---
|
|
525
|
+
|
|
526
|
+
## Review Checklist
|
|
527
|
+
|
|
528
|
+
### Dependency Injection
|
|
529
|
+
|
|
530
|
+
- [ ] Routes stay thin — DB access and business rules live behind `Depends`/services
|
|
531
|
+
- [ ] `yield` dependencies release resources via context manager or `try/finally`
|
|
532
|
+
- [ ] Singletons (HTTP clients, pools) created once in `lifespan`, not per request
|
|
533
|
+
- [ ] `Annotated[T, Depends(...)]` form used; dependencies are `async def` unless they do blocking I/O
|
|
534
|
+
- [ ] Existence/permission checks live in (cached) dependencies, not copy-pasted into routes
|
|
535
|
+
- [ ] Dependencies are overridable in tests (no resources created inline in the route)
|
|
536
|
+
|
|
537
|
+
### Validation
|
|
538
|
+
|
|
539
|
+
- [ ] Input and output use distinct Pydantic models; ORM objects are not the `response_model`
|
|
540
|
+
- [ ] `response_model` set so sensitive fields can't leak
|
|
541
|
+
- [ ] Separate Create vs Update schemas (update is partial)
|
|
542
|
+
- [ ] Constraints (`gt`, `le`, `EmailStr`, ...) enforced at the boundary, before the DB write
|
|
543
|
+
|
|
544
|
+
### Async
|
|
545
|
+
|
|
546
|
+
- [ ] No blocking calls (`requests`, `time.sleep`, blocking DB drivers) inside `async def`
|
|
547
|
+
- [ ] Native-async SDKs preferred (`httpx`, `asyncpg`, `redis.asyncio`, ...) over sync ones
|
|
548
|
+
- [ ] No `asyncio.run`/manual event loops/manual threads inside routes
|
|
549
|
+
- [ ] `run_in_threadpool`/`def` routes used only as a last resort, not on hot paths
|
|
550
|
+
- [ ] CPU-bound work offloaded to a worker process (Celery/Arq/RQ), not the loop or threadpool
|
|
551
|
+
- [ ] No unawaited coroutines; `BackgroundTasks` only for short fire-and-forget work
|
|
552
|
+
|
|
553
|
+
### Database
|
|
554
|
+
|
|
555
|
+
- [ ] One request-scoped session via dependency; no module-level shared session
|
|
556
|
+
- [ ] Relationships eager-loaded (`selectinload`/`joinedload`) where accessed in a loop
|
|
557
|
+
- [ ] Joins/aggregations done in SQL, not by looping in Python
|
|
558
|
+
- [ ] List endpoints are paginated with a capped `limit`
|
|
559
|
+
|
|
560
|
+
### Security
|
|
561
|
+
|
|
562
|
+
- [ ] Authentication dependency is backed by an explicit **authorization** check (ownership/role)
|
|
563
|
+
- [ ] All SQL parameterized; no f-string interpolation of user input
|
|
564
|
+
- [ ] CORS does not combine `allow_origins=["*"]` with `allow_credentials=True`
|
|
565
|
+
- [ ] Secrets come from config/env; error responses don't leak internals
|
|
566
|
+
|
|
567
|
+
### Tests
|
|
568
|
+
|
|
569
|
+
- [ ] Suspected bugs reproduced with a failing test (`TestClient`/`AsyncClient`) before being claimed
|
|
570
|
+
- [ ] `dependency_overrides` used instead of patching internals; overrides reset between tests
|
|
571
|
+
- [ ] Failure paths covered (401/403/404/422), not just the happy path
|
|
572
|
+
- [ ] Mocks of external responses are complete, not partial
|
|
573
|
+
- [ ] New tests demonstrate they can fail (reproduce-then-fix), not green from birth
|
|
574
|
+
|
|
575
|
+
---
|
|
576
|
+
|
|
577
|
+
## References
|
|
578
|
+
|
|
579
|
+
- [FastAPI official documentation](https://fastapi.tiangolo.com/) — async, dependencies, testing
|
|
580
|
+
- [zhanymkanov/fastapi-best-practices](https://github.com/zhanymkanov/fastapi-best-practices) — production conventions (async routes, dependency caching, project structure)
|