Course Content
FastAPI Essentials
1 sections · 32 lessons
When would you use HTTP directly instead of FastAPI’s dependency injection system?
What you need to know
The question is about choosing between two levels:
- High level: typed parameters and
Depends. FastAPI parses, validates, documents and injects. - Low level: the raw
Request(headers,await request.body(),request.stream()) or middleware that sees raw ASGI traffic. You get exactly what arrived, with no help.
Case 1: webhook signatures
A provider — a payment gateway, or an LLM platform telling you a fine-tuning job finished — signs the raw request body with a shared secret. You must compute the same signature over the same bytes.
1import hashlib, hmac2from fastapi import FastAPI, HTTPException, Request3from pydantic import BaseModel45SECRET = b"whsec_demo"6app = FastAPI()78class JobDone(BaseModel):9 job_id: str10 status: str1112def sign(raw: bytes) -> str:13 return hmac.new(SECRET, raw, hashlib.sha256).hexdigest()1415@app.post("/webhooks/fine-tune")16async def fine_tune_done(request: Request):17 raw = await request.body() # the exact bytes that were signed18 if not hmac.compare_digest(sign(raw), request.headers.get("x-signature", "")):19 raise HTTPException(401, "Bad signature")20 event = JobDone.model_validate_json(raw) # validate only after verifying21 return {"received": event.job_id}Real results, plus a naive version that declares event: JobDone and signs event.model_dump_json() instead:
correct signature -> {'received': 'ft-91'}body changed, same sig -> 401naive version (re-encoded) -> {'signature_ok': False}The naive version fails on a perfectly valid webhook. The sender wrote {"job_id": "ft-91", ...} with spaces; Pydantic re-encodes it as {"job_id":"ft-91",...} without them. Different bytes, different signature. Raw bytes are the only correct input here. Note also hmac.compare_digest, which compares in constant time so attackers cannot guess the signature from timing.
Other cases
| Situation | Use | Why not a typed parameter |
|---|---|---|
| Huge upload you want to stream to storage | async for chunk in request.stream() | A body model would load it all first |
| Proxy to another service unchanged | Raw body and headers | Parsing and re-encoding may change them |
| Request id, timing, access logs for all requests | Middleware | Runs even for 404s, which never reach a dependency |
| Calling another service | A shared httpx.AsyncClient (injected) | DI decides how you get the client, not how you call |
When not to go raw
Do not wrap trivial values in Depends for its own sake, and do not read request.json() by hand when a Pydantic model would do. Going raw removes validation, the typed object and the endpoint's OpenAPI description. Keep raw handling at the boundary and hand a validated model to the rest of the code.
A real-life example
A SaaS company fine-tunes customer models on an LLM platform, which calls a webhook when each job finishes. The first version declared event: JobDone and verified the signature over event.model_dump_json(). Every webhook failed verification, so the team "temporarily" turned verification off. For three weeks anyone who knew the URL could mark a job as finished.
The fix was the raw-body version above. The team also added a test that posts a captured real webhook, with its original bytes and signature, so any future refactor that breaks verification fails CI instead of being switched off.
Follow-up questions to expect
- "Can a dependency read the raw body?" — Yes. A dependency can take
request: Requestandawait request.body(); Starlette caches the body, so the route can still read it afterwards. - "Middleware or dependency for auth?" — Usually a dependency: it is per-route, typed, documented and testable. Middleware suits rules that truly apply to every path.
- "Why validate after verifying?" — So unauthenticated input never reaches your parsing code, and so the bytes you verified are the bytes you use.