Initial extracted documentation set
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
# Dependencies
|
||||
|
||||
Use FastAPI dependencies for request-scoped wiring: sessions, auth, permission checks, and other collaborators that should be assembled by the framework before the handler runs.
|
||||
|
||||
## Why this convention exists
|
||||
|
||||
Dependencies are FastAPI's main request-time composition tool. They let you:
|
||||
|
||||
- keep wiring visible in the handler signature
|
||||
- reuse auth and resource access across routes
|
||||
- give tests a clean override seam
|
||||
- manage `yield`-based resources with framework-controlled lifetime
|
||||
|
||||
That is better than opening sessions, parsing tokens, or repeating permission logic inside every route body.
|
||||
|
||||
## The convention
|
||||
|
||||
1. Put reusable request wiring in dependency callables.
|
||||
2. Use `Annotated[..., Depends(...)]` aliases when they make signatures easier to read.
|
||||
3. Keep handlers consuming already-shaped collaborators.
|
||||
4. Raise semantic HTTP errors inside dependencies when the failure is inherently about the request or caller.
|
||||
5. Use lifespan for app-wide startup resources; use dependencies for per-request access to them.
|
||||
|
||||
## Follow it when the concern is request-scoped
|
||||
|
||||
Typical fits:
|
||||
|
||||
- DB/session access
|
||||
- current-user lookup
|
||||
- permission gates
|
||||
- token/header parsing
|
||||
- reusable request validation
|
||||
|
||||
## Do not over-abstract it
|
||||
|
||||
Do *not* create a dependency just because a helper exists.
|
||||
|
||||
Skip dependency injection when:
|
||||
|
||||
- the logic is local to one route and trivial
|
||||
- the helper is plain business logic with no FastAPI concerns
|
||||
- the abstraction makes the signature harder to understand than the inline code would
|
||||
|
||||
Also avoid turning the dependency graph into a maze. If a reader has to chase six `Depends(...)` calls just to find the session, the abstraction is working against you.
|
||||
|
||||
## Preferred shape
|
||||
|
||||
```python
|
||||
from typing import Annotated
|
||||
from fastapi import Depends, HTTPException
|
||||
|
||||
|
||||
def get_db() -> Generator[Session, None, None]:
|
||||
with Session(engine) as session:
|
||||
yield session
|
||||
|
||||
|
||||
SessionDep = Annotated[Session, Depends(get_db)]
|
||||
|
||||
|
||||
def get_current_user(session: SessionDep, token: TokenDep) -> User:
|
||||
user = load_user_from_token(session, token)
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
return user
|
||||
|
||||
|
||||
CurrentUser = Annotated[User, Depends(get_current_user)]
|
||||
|
||||
|
||||
@router.get("/items/{id}")
|
||||
def read_item(session: SessionDep, current_user: CurrentUser, id: UUID) -> ItemPublic:
|
||||
...
|
||||
```
|
||||
|
||||
Why this works:
|
||||
|
||||
- the route tells you exactly what it needs
|
||||
- auth and session logic are reusable
|
||||
- tests can swap dependencies without patching route internals
|
||||
|
||||
## Counterexamples
|
||||
|
||||
### Hidden wiring inside handlers
|
||||
|
||||
```python
|
||||
@router.get("/items/{id}")
|
||||
def read_item(id: UUID):
|
||||
session = Session(engine)
|
||||
token = parse_auth_header_somehow()
|
||||
user = load_user(session, token)
|
||||
...
|
||||
```
|
||||
|
||||
Bad because resource and auth wiring are duplicated and easy to get inconsistently wrong.
|
||||
|
||||
### Business logic buried in dependencies
|
||||
|
||||
```python
|
||||
def get_processed_report(...) -> Report:
|
||||
# loads DB, runs domain rules, emails people, mutates state, etc.
|
||||
```
|
||||
|
||||
Bad because dependencies should shape request collaborators, not become a second invisible service layer.
|
||||
|
||||
### Startup work disguised as a dependency
|
||||
|
||||
If initialization is app-wide and long-lived, it belongs in lifespan, not in a request dependency that quietly performs setup.
|
||||
|
||||
## Source signals
|
||||
|
||||
### FastAPI
|
||||
|
||||
- `docs_src/bigger_applications/app_an_py310/dependencies.py:6-13` uses a dedicated dependency callable for request validation and raises an HTTP error there.
|
||||
- `fastapi/routing.py:95-136` wraps request handling in `AsyncExitStack`, which is the mechanism FastAPI uses to manage dependency lifetime, including `yield`-based dependencies.
|
||||
- `tests/test_dependency_security_overrides.py:24-29` defines a route whose collaborators come from `Security(...)` and `Depends(...)` rather than inline setup.
|
||||
- `tests/test_dependency_security_overrides.py:44-63` replaces those collaborators through `app.dependency_overrides`, which is the testing seam FastAPI expects you to use.
|
||||
|
||||
### Full-stack FastAPI Template
|
||||
|
||||
- `backend/app/api/deps.py:21-27` defines a yielded DB session dependency and exposes it as `SessionDep`.
|
||||
- `backend/app/api/deps.py:30-57` decodes auth, loads the user, and raises semantic HTTP errors in a reusable current-user dependency.
|
||||
- `backend/app/api/routes/items.py:13-16` shows the result in practice: the route signature receives `SessionDep` and `CurrentUser` directly.
|
||||
|
||||
### Starlette and FastAPI Users
|
||||
|
||||
- `starlette/applications.py:46-48` says lifespan is the preferred replacement for `on_startup` and `on_shutdown`, reinforcing the split between app-wide resource setup and request-time dependencies.
|
||||
- `examples/beanie-oauth/app/app.py:17-28` initializes Beanie in lifespan.
|
||||
- `examples/beanie-oauth/app/app.py:60-62` protects a route with `Depends(current_active_user)` instead of doing auth lookup inline.
|
||||
|
||||
## Bottom line
|
||||
|
||||
Dependencies should make request wiring more obvious, more reusable, and easier to test.
|
||||
|
||||
If a dependency hides core business behavior, it is probably the wrong abstraction.
|
||||
@@ -0,0 +1,121 @@
|
||||
# Errors
|
||||
|
||||
Use handlers and dependencies to signal HTTP meaning clearly, and let FastAPI or Starlette own the wire format.
|
||||
|
||||
## Why this convention exists
|
||||
|
||||
FastAPI sits at a boundary where several kinds of failures meet:
|
||||
|
||||
- invalid requests
|
||||
- permission failures
|
||||
- missing resources
|
||||
- domain or infrastructure errors
|
||||
- framework/runtime exceptions
|
||||
|
||||
Good code keeps those concerns separate.
|
||||
|
||||
The stable pattern is:
|
||||
|
||||
- handlers and dependencies raise semantic HTTP exceptions for boundary failures
|
||||
- request validation errors come from FastAPI and Pydantic automatically
|
||||
- exception handlers turn those failures into HTTP responses
|
||||
- tests assert the HTTP contract, not private exception plumbing
|
||||
|
||||
That keeps route code small and error responses consistent.
|
||||
|
||||
## The convention
|
||||
|
||||
1. Raise `HTTPException` for request-level failures such as 400, 401, 403, and 404.
|
||||
2. Let FastAPI and Pydantic produce validation errors instead of hand-rolling error dicts.
|
||||
3. Centralize error-envelope formatting in exception handlers when the API needs a stable shape.
|
||||
4. Keep internal exception details out of client responses unless that disclosure is part of the contract.
|
||||
5. Keep domain exceptions framework-agnostic and translate them at the HTTP boundary.
|
||||
|
||||
## Follow it at the HTTP boundary
|
||||
|
||||
Use this whenever the failure is about what the caller sent, what the caller can access, or what the API contract should look like on failure.
|
||||
|
||||
## Avoid these mistakes
|
||||
|
||||
Do *not* raise `HTTPException` from deep reusable domain code.
|
||||
|
||||
Do *not* build ad hoc error JSON in each route.
|
||||
|
||||
Do *not* catch every exception just to expose `str(exc)` to clients.
|
||||
|
||||
## Preferred shape
|
||||
|
||||
```python
|
||||
@router.get("/{id}")
|
||||
def read_item(session: SessionDep, current_user: CurrentUser, id: UUID) -> ItemPublic:
|
||||
item = session.get(Item, id)
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="Item not found")
|
||||
if item.owner_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="Not enough permissions")
|
||||
return item
|
||||
```
|
||||
|
||||
Why this works:
|
||||
|
||||
- the failure is explicit at the boundary
|
||||
- the status code carries the meaning
|
||||
- the framework serializes it consistently
|
||||
|
||||
## Counterexamples
|
||||
|
||||
### Returning error dicts from handlers
|
||||
|
||||
```python
|
||||
return {"error": "not_found", "message": "Item not found"}
|
||||
```
|
||||
|
||||
Bad because success and failure flows blur together and status handling becomes inconsistent.
|
||||
|
||||
### Framework exceptions in core domain code
|
||||
|
||||
```python
|
||||
class BillingService:
|
||||
def charge(...):
|
||||
raise HTTPException(status_code=402, detail="Payment required")
|
||||
```
|
||||
|
||||
Bad because domain code is now coupled to one web framework.
|
||||
|
||||
### Catch-all 500 handling that leaks internals
|
||||
|
||||
```python
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=str(exc))
|
||||
```
|
||||
|
||||
Bad because it leaks internal details and destroys useful operational distinctions.
|
||||
|
||||
## Source signals
|
||||
|
||||
### FastAPI
|
||||
|
||||
- `fastapi/exception_handlers.py:11-26` is the core citation here: `HTTPException` becomes a JSON `{"detail": ...}` response when a body is allowed, and `RequestValidationError` becomes a 422 response using `exc.errors()`.
|
||||
- `docs_src/bigger_applications/app_an_py310/routers/items.py:21-25` raises `HTTPException(status_code=404, detail="Item not found")` for a missing resource.
|
||||
- `docs_src/bigger_applications/app_an_py310/routers/items.py:33-37` raises `HTTPException(status_code=403, detail=...)` for a forbidden update.
|
||||
|
||||
### Full-stack FastAPI Template
|
||||
|
||||
- `backend/app/api/deps.py:30-45` translates invalid token, missing user, and inactive user states into semantic HTTP errors inside a dependency.
|
||||
- `backend/app/api/routes/items.py:53-57` uses 404 and 403 in the read handler.
|
||||
- `backend/app/api/routes/items.py:86-95` repeats the same boundary pattern on update before applying the mutation.
|
||||
- `backend/app/api/routes/items.py:106-112` repeats it on delete before committing the write.
|
||||
|
||||
### Pydantic and Starlette
|
||||
|
||||
- `pydantic/docs/index.md:109-152` shows invalid input producing structured validation errors rather than ad hoc strings.
|
||||
- `starlette/tests/test_applications.py:218-238` asserts 404, custom 405, and 500 responses at the HTTP layer, reinforcing that response behavior is a framework contract, not just an implementation detail.
|
||||
- `starlette/applications.py:35-48` separates middleware and exception handlers in application setup, which is part of why centralized formatting works cleanly.
|
||||
|
||||
## Bottom line
|
||||
|
||||
Routes and dependencies should express *HTTP meaning*.
|
||||
|
||||
Exception handlers should own *HTTP formatting*.
|
||||
|
||||
Core logic should stay framework-agnostic until the boundary translates it.
|
||||
@@ -0,0 +1,127 @@
|
||||
# Persistence
|
||||
|
||||
Keep session lifetime and write boundaries explicit. Route handlers can orchestrate straightforward database work, but persistence concerns should stay visible instead of hiding behind ambient sessions or magical commits.
|
||||
|
||||
## Why this convention exists
|
||||
|
||||
FastAPI apps often stay clean until persistence gets sloppy. Then you get:
|
||||
|
||||
- hidden session creation inside helpers
|
||||
- unclear transaction boundaries
|
||||
- handlers packed with ORM choreography
|
||||
- tests that only pass because the database layer was mocked out of existence
|
||||
|
||||
The stronger pattern in mature examples is straightforward:
|
||||
|
||||
- sessions arrive through dependencies
|
||||
- read and write boundaries are visible in the calling flow
|
||||
- sync and async persistence are deliberate choices
|
||||
- tests exercise real session lifetime and cleanup
|
||||
|
||||
## The convention
|
||||
|
||||
1. Provide sessions through dependencies.
|
||||
2. Keep transaction and commit boundaries visible.
|
||||
3. Make sync versus async persistence an explicit stack decision.
|
||||
4. Let routes do simple persistence orchestration, but move reusable business rules out once the flow stops being trivial.
|
||||
5. Test against real persistence seams when practical.
|
||||
|
||||
## Follow it when stored state matters
|
||||
|
||||
Typical fits:
|
||||
|
||||
- routes that read or write the database
|
||||
- flows where transaction scope matters
|
||||
- auth-aware queries
|
||||
- cleanup-sensitive tests
|
||||
|
||||
## Avoid these mistakes
|
||||
|
||||
Do *not* invent a repository or service layer for every one-line query.
|
||||
|
||||
Do *not* hide session creation deep inside helper functions.
|
||||
|
||||
Do *not* force async persistence just because the web layer is async. Choose sync or async deliberately based on the real stack.
|
||||
|
||||
## Preferred shapes
|
||||
|
||||
### Session through dependency
|
||||
|
||||
```python
|
||||
def get_db() -> Generator[Session, None, None]:
|
||||
with Session(engine) as session:
|
||||
yield session
|
||||
```
|
||||
|
||||
This makes resource lifetime explicit and testable.
|
||||
|
||||
### Visible write boundary
|
||||
|
||||
```python
|
||||
item = Item.model_validate(item_in, update={"owner_id": current_user.id})
|
||||
session.add(item)
|
||||
session.commit()
|
||||
session.refresh(item)
|
||||
return item
|
||||
```
|
||||
|
||||
This is honest about where state changes happen.
|
||||
|
||||
### Explicit partial-update flow
|
||||
|
||||
```python
|
||||
update_dict = item_in.model_dump(exclude_unset=True)
|
||||
item.sqlmodel_update(update_dict)
|
||||
session.add(item)
|
||||
session.commit()
|
||||
session.refresh(item)
|
||||
```
|
||||
|
||||
Again: no hidden writes, no mystery transaction.
|
||||
|
||||
## Counterexamples
|
||||
|
||||
### Hidden ambient session access
|
||||
|
||||
```python
|
||||
def create_item(item_in: ItemCreate) -> Item:
|
||||
session = Session(engine)
|
||||
...
|
||||
```
|
||||
|
||||
Bad because session lifetime is buried in the wrong layer.
|
||||
|
||||
### Commit hidden deep in helpers
|
||||
|
||||
If callers cannot tell where the write boundary is, transaction reasoning gets ugly fast.
|
||||
|
||||
### Mock-only persistence tests
|
||||
|
||||
If no test ever exercises real session lifetime, query shape, or cleanup behavior, the persistence layer will surprise you in production.
|
||||
|
||||
## Source signals
|
||||
|
||||
### Full-stack FastAPI Template
|
||||
|
||||
- `backend/app/api/deps.py:21-27` provides DB access through a yielded session dependency.
|
||||
- `backend/app/api/routes/items.py:21-45` keeps read query shape explicit in the route, including auth-aware filtering and response shaping.
|
||||
- `backend/app/api/routes/items.py:68-72` shows a visible create, commit, and refresh boundary.
|
||||
- `backend/app/api/routes/items.py:86-95` shows the same visibility on update.
|
||||
- `backend/app/api/routes/items.py:106-112` shows delete followed by an explicit commit.
|
||||
- `backend/tests/conftest.py:15-24` keeps test session lifetime and cleanup explicit.
|
||||
|
||||
### SQLAlchemy and Starlette lifespan
|
||||
|
||||
- `examples/asyncio/async_orm.py:63-104` in SQLAlchemy shows explicit async session and transaction handling instead of hidden ambient state.
|
||||
- `examples/inheritance/joined.py:93-120` shows the sync equivalent with visible `Session(...)` and `commit()` boundaries.
|
||||
- `starlette/applications.py:46-48` reinforces that long-lived app resources belong in lifespan rather than being initialized lazily inside request-time persistence helpers.
|
||||
|
||||
## Bottom line
|
||||
|
||||
Persistence should be visible.
|
||||
|
||||
Sessions should come from clear boundaries.
|
||||
|
||||
Transactions should not be magical.
|
||||
|
||||
If a reader cannot tell where data is loaded, changed, and committed, the persistence design needs work.
|
||||
@@ -0,0 +1,121 @@
|
||||
# Pydantic Boundaries
|
||||
|
||||
Use Pydantic models deliberately at FastAPI transport boundaries. They are excellent for parsing, validation, and serialization, but they should not automatically become your entire domain model.
|
||||
|
||||
## Why this convention exists
|
||||
|
||||
FastAPI and Pydantic fit together especially well at request and response boundaries:
|
||||
|
||||
- parsing external input
|
||||
- validating shape and types
|
||||
- coercing raw values
|
||||
- serializing output
|
||||
- generating OpenAPI schema
|
||||
|
||||
That convenience is the point. The trap is letting one transport model silently become the request model, response model, persistence model, and domain model for accidental reasons rather than deliberate ones.
|
||||
|
||||
## The convention
|
||||
|
||||
1. Use Pydantic models for request and response boundaries.
|
||||
2. Validate external input explicitly.
|
||||
3. Serialize explicitly at the response boundary.
|
||||
4. Translate between transport models and domain or persistence objects when the concerns diverge.
|
||||
5. Keep field-level validation and coercion on the schema that owns those rules.
|
||||
|
||||
## Follow it when data crosses the API boundary
|
||||
|
||||
Typical fits:
|
||||
|
||||
- request bodies
|
||||
- response models
|
||||
- field aliasing or defaulting rules
|
||||
- API shapes that should stay stable even if internal objects change
|
||||
|
||||
## Do not split models just to satisfy a slogan
|
||||
|
||||
A tiny app may genuinely use the same shape all the way through.
|
||||
|
||||
But split models once the concerns stop matching, especially when:
|
||||
|
||||
- API names diverge from internal names
|
||||
- persistence fields should not leak outward
|
||||
- the response shape is not the storage shape
|
||||
- business invariants deserve their own representation
|
||||
|
||||
## Preferred shapes
|
||||
|
||||
### Request model at the boundary, explicit translation inward
|
||||
|
||||
```python
|
||||
@router.post("/items", response_model=ItemPublic)
|
||||
def create_item(session: SessionDep, current_user: CurrentUser, item_in: ItemCreate) -> ItemPublic:
|
||||
item = Item.model_validate(item_in, update={"owner_id": current_user.id})
|
||||
session.add(item)
|
||||
session.commit()
|
||||
session.refresh(item)
|
||||
return item
|
||||
```
|
||||
|
||||
This keeps request parsing explicit while still allowing a translation step into the stored object.
|
||||
|
||||
### Explicit partial-update semantics
|
||||
|
||||
```python
|
||||
update_dict = item_in.model_dump(exclude_unset=True)
|
||||
```
|
||||
|
||||
That is clearer than guessing which missing fields mean "leave unchanged."
|
||||
|
||||
## Counterexamples
|
||||
|
||||
### One transport schema leaked into every layer
|
||||
|
||||
```python
|
||||
class UserPayload(BaseModel):
|
||||
...
|
||||
|
||||
# same class used for HTTP input, DB persistence, internal domain state,
|
||||
# background jobs, and every outbound response
|
||||
```
|
||||
|
||||
Sometimes fine for a toy app; usually brittle over time.
|
||||
|
||||
### Ad hoc dict shaping in handlers
|
||||
|
||||
```python
|
||||
return {"id": item.id, "name": item.name, "owner": current_user.email}
|
||||
```
|
||||
|
||||
Bad because the response contract drifts away from any explicit schema.
|
||||
|
||||
### Validation rules smeared across unrelated layers
|
||||
|
||||
If field coercion belongs to the request schema, keep it there instead of duplicating it in routes, services, and ORM hooks.
|
||||
|
||||
## Source signals
|
||||
|
||||
### Pydantic
|
||||
|
||||
- `pydantic/main.py:253-264` states that model construction parses and validates input data and raises `ValidationError` when the input cannot form a valid model.
|
||||
- `pydantic/docs/index.md:68-107` shows external data becoming a typed model and then being serialized with `model_dump()`.
|
||||
- `pydantic/docs/index.md:109-152` shows invalid input producing structured validation errors.
|
||||
- `pydantic/docs/concepts/validators.md:91-114` shows field-scoped `mode='after'` validation.
|
||||
- `pydantic/docs/concepts/validators.md:160-252` shows `mode='before'` validators operating on raw input before normal parsing.
|
||||
|
||||
### Full-stack FastAPI Template
|
||||
|
||||
- `backend/app/api/routes/items.py:61-72` accepts `ItemCreate`, translates it into an `Item`, commits it, and returns the stored object.
|
||||
- `backend/app/api/routes/items.py:44-45` converts query results into `ItemPublic` response models for collection responses.
|
||||
- `backend/app/api/routes/items.py:91-95` uses `model_dump(exclude_unset=True)` before applying a patch-style update.
|
||||
|
||||
### FastAPI
|
||||
|
||||
- `fastapi/exception_handlers.py:20-26` turns request validation failures into a consistent 422 response instead of leaving each route to invent its own error format.
|
||||
|
||||
## Bottom line
|
||||
|
||||
Pydantic belongs at the HTTP boundary by default.
|
||||
|
||||
Use it there aggressively.
|
||||
|
||||
When transport concerns and domain concerns diverge, translate between them instead of forcing one class to own every job forever.
|
||||
@@ -0,0 +1,120 @@
|
||||
# Routes
|
||||
|
||||
Keep route handlers thin. They should own HTTP concerns, not become the place where auth, persistence, validation, and business rules all pile up.
|
||||
|
||||
## Why this convention exists
|
||||
|
||||
FastAPI already gives routes strong boundary mechanics: parsed inputs, dependency injection, response-model shaping, and framework-managed error handling. The clean pattern in both FastAPI examples and production templates is simple:
|
||||
|
||||
- declare request inputs in the signature
|
||||
- accept collaborators from dependencies
|
||||
- do small boundary checks
|
||||
- call the next layer
|
||||
- return a typed response
|
||||
|
||||
When handlers grow past that, they become hard to read, hard to reuse, and hard to test.
|
||||
|
||||
## The convention
|
||||
|
||||
1. Keep HTTP metadata on the router or route decorator.
|
||||
2. Keep request parsing explicit in the function signature.
|
||||
3. Use dependencies for shared request wiring.
|
||||
4. Keep reusable or non-trivial business rules outside the handler.
|
||||
5. Return explicit response models or well-understood objects that FastAPI can serialize predictably.
|
||||
|
||||
## Follow it by default
|
||||
|
||||
This fits almost every application route.
|
||||
|
||||
"Thin" does *not* mean the route does nothing. A good route can still:
|
||||
|
||||
- load a record
|
||||
- perform obvious 404/403 checks
|
||||
- map an input model into a stored object
|
||||
- commit and return a response
|
||||
|
||||
The smell is not "the route did work." The smell is "the route became the system's junk drawer."
|
||||
|
||||
## Preferred shape
|
||||
|
||||
```python
|
||||
@router.get("/{id}", response_model=ItemPublic)
|
||||
def read_item(session: SessionDep, current_user: CurrentUser, id: UUID) -> ItemPublic:
|
||||
item = session.get(Item, id)
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="Item not found")
|
||||
if not current_user.is_superuser and item.owner_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="Not enough permissions")
|
||||
return item
|
||||
```
|
||||
|
||||
Why this works:
|
||||
|
||||
- inputs and collaborators are visible in one line
|
||||
- boundary failures are explicit
|
||||
- the handler stays readable without inventing extra layers
|
||||
|
||||
## Counterexamples
|
||||
|
||||
### Handler as a god function
|
||||
|
||||
```python
|
||||
@router.post("/")
|
||||
def create_item(...):
|
||||
# parse auth manually
|
||||
# open DB manually
|
||||
# validate request shape again
|
||||
# run business rules
|
||||
# send email
|
||||
# publish event
|
||||
# build custom response dict
|
||||
```
|
||||
|
||||
Bad because every concern is fused into one boundary blob.
|
||||
|
||||
### Hidden response shaping
|
||||
|
||||
```python
|
||||
return {
|
||||
"ok": True,
|
||||
"item": some_orm_obj.__dict__,
|
||||
"extra": weird_runtime_state,
|
||||
}
|
||||
```
|
||||
|
||||
Bad because the API contract becomes informal and drifts easily.
|
||||
|
||||
### Same ownership check copied into every handler body
|
||||
|
||||
If the wiring is structurally shared, move it toward a dependency or helper instead of repeating it across routes.
|
||||
|
||||
## Source signals
|
||||
|
||||
### FastAPI
|
||||
|
||||
- `docs_src/bigger_applications/app_an_py310/routers/items.py:5-10` puts prefix, tags, shared dependencies, and canned responses on the router instead of inside each handler.
|
||||
- `docs_src/bigger_applications/app_an_py310/routers/items.py:16-38` keeps the handlers tiny: one returns a collection, one raises a 404 for a missing item, and one raises a 403 for a forbidden update.
|
||||
- `docs_src/bigger_applications/app_an_py310/main.py:1-18` assembles routers centrally, which is another signal that route modules are boundary slices, not the whole application.
|
||||
|
||||
### Full-stack FastAPI Template
|
||||
|
||||
- `backend/app/api/routes/items.py:13-45` keeps list handling inside a single readable flow: explicit params, auth-aware filtering, and response-model construction.
|
||||
- `backend/app/api/routes/items.py:48-58` shows a narrow read-by-id handler with only fetch + permission checks + return.
|
||||
- `backend/app/api/routes/items.py:61-72` shows that simple persistence orchestration can still live in the route when the flow is straightforward.
|
||||
- `backend/app/api/main.py:6-14` centralizes router registration instead of scattering feature mounting across the codebase.
|
||||
|
||||
### FastAPI Users
|
||||
|
||||
- `examples/beanie-oauth/app/app.py:30-57` composes feature routers at app assembly time instead of stuffing auth flows into one giant module.
|
||||
- `examples/beanie-oauth/app/app.py:60-62` keeps an authenticated endpoint tiny because auth comes from a dependency.
|
||||
|
||||
## Bottom line
|
||||
|
||||
A good FastAPI route is a clean HTTP boundary:
|
||||
|
||||
- explicit inputs
|
||||
- explicit collaborators
|
||||
- explicit failures
|
||||
- explicit outputs
|
||||
|
||||
If a handler starts absorbing subsystems, pull those concerns back out.
|
||||
@@ -0,0 +1,138 @@
|
||||
# Testing
|
||||
|
||||
Test FastAPI applications at the HTTP or ASGI boundary whenever practical. Prefer client-based tests, dependency overrides, and explicit lifespan or session fixtures over patching internals.
|
||||
|
||||
## Why this convention exists
|
||||
|
||||
FastAPI does a lot of important work *around* your code:
|
||||
|
||||
- request parsing
|
||||
- dependency injection
|
||||
- auth wiring
|
||||
- validation
|
||||
- error serialization
|
||||
- startup and shutdown behavior
|
||||
|
||||
If tests bypass those seams, they skip the behavior the framework is responsible for.
|
||||
|
||||
The repeated pattern across FastAPI, Starlette, HTTPX, and production templates is:
|
||||
|
||||
- drive the app through a client
|
||||
- make fixture lifetime explicit
|
||||
- override dependencies at the framework seam
|
||||
- exercise lifespan behavior deliberately
|
||||
|
||||
## The convention
|
||||
|
||||
1. Use `TestClient` or HTTPX ASGI clients for route tests.
|
||||
2. Put shared app, client, and session setup in fixtures.
|
||||
3. Use `app.dependency_overrides` to replace request-time collaborators.
|
||||
4. Use `yield` fixtures when cleanup matters.
|
||||
5. Enter lifespan-aware client contexts when startup or shutdown behavior is part of the test.
|
||||
|
||||
## Follow it for boundary behavior
|
||||
|
||||
This is the default for:
|
||||
|
||||
- route tests
|
||||
- auth and permission tests
|
||||
- validation and error-contract tests
|
||||
- startup and shutdown behavior
|
||||
- request and response schema behavior
|
||||
|
||||
Pure business logic is different: if the code is framework-agnostic, test it directly as normal Python.
|
||||
|
||||
## Preferred shapes
|
||||
|
||||
### Client fixture with explicit lifetime
|
||||
|
||||
```python
|
||||
@pytest.fixture
|
||||
def client() -> Generator[TestClient, None, None]:
|
||||
with TestClient(app) as client:
|
||||
yield client
|
||||
```
|
||||
|
||||
This makes startup and teardown behavior visible.
|
||||
|
||||
### Dependency overrides for request-time seams
|
||||
|
||||
```python
|
||||
app.dependency_overrides[get_current_user] = fake_user
|
||||
try:
|
||||
response = client.get("/items")
|
||||
finally:
|
||||
app.dependency_overrides = {}
|
||||
```
|
||||
|
||||
This swaps collaborators where FastAPI expects them to be swapped.
|
||||
|
||||
### Explicit DB or session cleanup fixture
|
||||
|
||||
```python
|
||||
@pytest.fixture(scope="session", autouse=True)
|
||||
def db() -> Generator[Session, None, None]:
|
||||
with Session(engine) as session:
|
||||
init_db(session)
|
||||
yield session
|
||||
cleanup_db(session)
|
||||
session.commit()
|
||||
```
|
||||
|
||||
This makes resource lifetime and cleanup rules obvious.
|
||||
|
||||
## Counterexamples
|
||||
|
||||
### Monkeypatch soup
|
||||
|
||||
```python
|
||||
monkeypatch.setattr("app.api.routes.items.session", fake_session)
|
||||
monkeypatch.setattr("app.api.routes.items.get_user", fake_user)
|
||||
```
|
||||
|
||||
Bad because the test couples to route internals instead of public seams.
|
||||
|
||||
### Testing startup-sensitive behavior without entering client context
|
||||
|
||||
If the app depends on startup-initialized resources, a client used without the proper lifespan context can produce misleading green tests.
|
||||
|
||||
### Calling route functions directly for integration-style assertions
|
||||
|
||||
That skips request parsing, dependency injection, validation, and error serialization, which is most of what FastAPI is doing for you.
|
||||
|
||||
## Source signals
|
||||
|
||||
### FastAPI
|
||||
|
||||
- `tests/test_dependency_security_overrides.py:24-29` defines a route whose collaborators come from dependency injection.
|
||||
- `tests/test_dependency_security_overrides.py:44-63` overrides those collaborators with `app.dependency_overrides` and then resets the override map.
|
||||
|
||||
### Starlette
|
||||
|
||||
- `tests/test_applications.py:160-163` uses a yielded `client` fixture around a test client context manager.
|
||||
- `tests/test_applications.py:234-238` uses `raise_server_exceptions=False` so tests can inspect a 500 response instead of immediately re-raising the server exception.
|
||||
- `tests/test_applications.py:394-409` verifies lifespan startup before `yield` and cleanup after `yield` through the client context.
|
||||
- `starlette/applications.py:46-48` explicitly documents lifespan as the preferred startup and shutdown mechanism.
|
||||
|
||||
### HTTPX
|
||||
|
||||
- `httpx/_transports/asgi.py:63-83` exposes `ASGITransport` specifically for driving an ASGI app in-process, including testing-oriented constructor options.
|
||||
- `httpx/_transports/asgi.py:78-81` documents `raise_app_exceptions=False` for inspecting application failures as responses.
|
||||
- `httpx/_transports/mock.py:15-43` exposes `MockTransport` as an explicit transport seam when testing HTTP clients.
|
||||
|
||||
### Full-stack FastAPI Template
|
||||
|
||||
- `backend/tests/conftest.py:15-24` keeps DB setup and cleanup explicit in a yielded session fixture.
|
||||
- `backend/tests/conftest.py:27-30` defines a `TestClient` fixture with a visible context boundary.
|
||||
- `backend/tests/conftest.py:33-42` derives auth fixtures from the client instead of bypassing the app boundary.
|
||||
|
||||
## Bottom line
|
||||
|
||||
Test the app where FastAPI actually does work:
|
||||
|
||||
- through the client
|
||||
- through dependencies
|
||||
- through lifespan
|
||||
- with explicit fixture lifetime
|
||||
|
||||
Patch internals only when there is no better seam.
|
||||
Reference in New Issue
Block a user