start-vibing-stacks 2.17.0 → 2.18.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.
@@ -1,134 +1,349 @@
1
1
  ---
2
2
  name: fastapi-patterns
3
- version: 1.0.0
3
+ version: 2.0.0
4
+ description: "FastAPI patterns for Python 3.13/3.14 production APIs in 2026. Covers lifespan context manager (replaces deprecated @app.on_event), Pydantic V2 (Rust-backed), SQLAlchemy 2.0 async with Mapped[] type hints + async_sessionmaker(expire_on_commit=False) + pool_pre_ping, dependency injection with @lru_cache for settings and yield-based deps for cleanup, mandatory async/sync separation (NEVER mix sync DB driver in async def — freezes the worker), security headers + CORS via Starlette middleware, BackgroundTasks vs Celery/ARQ, deployment with uvicorn (production-ready since 2024 — gunicorn no longer required for most workloads). Invoke when writing FastAPI routes, dependencies, middleware, lifespan, or DB integration."
4
5
  ---
5
6
 
6
- # FastAPI Patterns — High-Performance Async APIs
7
+ # FastAPI Patterns — Production APIs (2026)
7
8
 
8
- **ALWAYS invoke when writing FastAPI routes, dependencies, or middleware.**
9
+ **ALWAYS invoke when writing FastAPI routes, dependencies, middleware, lifespan, or DB integration.**
9
10
 
10
- ## Route Pattern
11
+ > Pair with `pydantic-validation` for schemas, `api-security-python` for OWASP/Sanctum equivalents, `async-patterns` for `asyncio.timeout`/TaskGroup, `pytest-testing` for `httpx.AsyncClient` test client.
12
+
13
+ ## Project Structure
14
+
15
+ ```
16
+ app/
17
+ ├── main.py # FastAPI app + lifespan
18
+ ├── api/
19
+ │ ├── v1/
20
+ │ │ ├── routes/ # Thin endpoint handlers (HTTP only)
21
+ │ │ └── deps.py # Auth, db, current_user, request_id
22
+ │ └── deps.py
23
+ ├── core/
24
+ │ ├── config.py # Pydantic Settings, @lru_cache
25
+ │ ├── security.py # JWT issue/verify, password hashing
26
+ │ └── exceptions.py
27
+ ├── db/
28
+ │ ├── base.py # SQLAlchemy DeclarativeBase
29
+ │ ├── models/ # SQLAlchemy ORM (Mapped[])
30
+ │ └── session.py # engine + async_sessionmaker
31
+ ├── schemas/ # Pydantic V2 models (Base/Create/Update/Response)
32
+ ├── services/ # Business logic — routers DELEGATE here
33
+ └── tests/
34
+ ```
35
+
36
+ ## Lifespan — startup/shutdown the modern way
37
+
38
+ `@app.on_event("startup"|"shutdown")` is **deprecated**. Use a single `lifespan` async context manager:
11
39
 
12
40
  ```python
13
- from fastapi import APIRouter, Depends, HTTPException, status
41
+ # app/main.py
42
+ from contextlib import asynccontextmanager
43
+ from fastapi import FastAPI
44
+ import httpx
45
+
46
+ from app.db.session import engine
47
+ from app.core.config import settings
48
+
49
+ @asynccontextmanager
50
+ async def lifespan(app: FastAPI):
51
+ # ---- startup ----
52
+ app.state.http = httpx.AsyncClient(timeout=10.0)
53
+ yield
54
+ # ---- shutdown ----
55
+ await app.state.http.aclose()
56
+ await engine.dispose()
57
+
58
+ app = FastAPI(
59
+ title="MyAPI",
60
+ version="1.0",
61
+ lifespan=lifespan,
62
+ docs_url="/docs" if settings.DEBUG else None,
63
+ )
64
+ ```
65
+
66
+ Anything that needs setup once and teardown on shutdown (HTTP clients, DB pool, message broker, cache) goes here — not in module-level globals.
67
+
68
+ ## Route — thin, typed, delegates
69
+
70
+ ```python
71
+ from fastapi import APIRouter, Depends, status
14
72
  from app.schemas.user import UserCreate, UserResponse
15
73
  from app.services.user import UserService
16
- from app.api.deps import get_current_user, get_db
74
+ from app.api.v1.deps import get_user_service, get_current_user
17
75
 
18
76
  router = APIRouter(prefix="/users", tags=["users"])
19
77
 
20
- @router.post("/", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
21
- async def create_user(data: UserCreate, db=Depends(get_db)):
22
- service = UserService(db)
23
- return await service.create(data)
78
+ @router.post(
79
+ "",
80
+ response_model=UserResponse,
81
+ status_code=status.HTTP_201_CREATED,
82
+ summary="Create user",
83
+ )
84
+ async def create_user(
85
+ body: UserCreate,
86
+ svc: UserService = Depends(get_user_service),
87
+ ) -> UserResponse:
88
+ return await svc.create(body)
24
89
 
25
90
  @router.get("/me", response_model=UserResponse)
26
- async def get_me(user=Depends(get_current_user)):
91
+ async def get_me(user = Depends(get_current_user)) -> UserResponse:
27
92
  return user
28
93
  ```
29
94
 
30
- ## Dependency Injection
95
+ Routes don't touch the DB. Services do. Routes don't catch exceptions to translate codes either — exception handlers do.
96
+
97
+ ## Dependency Injection — patterns
31
98
 
32
99
  ```python
33
- # app/api/deps.py
100
+ # app/api/v1/deps.py
101
+ from functools import lru_cache
34
102
  from fastapi import Depends, HTTPException, status
35
- from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
103
+ from fastapi.security import OAuth2PasswordBearer
104
+ from sqlalchemy.ext.asyncio import AsyncSession
36
105
 
37
- security = HTTPBearer()
106
+ from app.core.config import Settings, get_settings # @lru_cache below
107
+ from app.db.session import async_session
108
+ from app.services.user import UserService
109
+
110
+ oauth2_scheme = OAuth2PasswordBearer(tokenUrl="auth/login")
38
111
 
39
- async def get_db():
112
+ # yield-based — auto cleanup on response (or exception)
113
+ async def get_db() -> AsyncSession:
40
114
  async with async_session() as session:
41
- yield session # Auto-cleanup
115
+ try:
116
+ yield session
117
+ except Exception:
118
+ await session.rollback()
119
+ raise
120
+
121
+ # composition — DI just plugs sub-deps in
122
+ def get_user_service(db: AsyncSession = Depends(get_db)) -> UserService:
123
+ return UserService(db)
42
124
 
43
125
  async def get_current_user(
44
- credentials: HTTPAuthorizationCredentials = Depends(security),
45
- db=Depends(get_db),
46
- ) -> User:
47
- user = await verify_token(credentials.credentials, db)
126
+ token: str = Depends(oauth2_scheme),
127
+ svc: UserService = Depends(get_user_service),
128
+ ):
129
+ user = await svc.from_token(token)
48
130
  if not user:
49
- raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
131
+ raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid token")
50
132
  return user
51
133
  ```
52
134
 
53
- ## Pydantic Settings (env config)
54
-
55
135
  ```python
56
- from pydantic_settings import BaseSettings
136
+ # app/core/config.py — settings cached, validated at import
137
+ from functools import lru_cache
138
+ from pydantic_settings import BaseSettings, SettingsConfigDict
57
139
 
58
140
  class Settings(BaseSettings):
141
+ model_config = SettingsConfigDict(env_file=".env", extra="forbid")
142
+
59
143
  DATABASE_URL: str
60
144
  SECRET_KEY: str
61
145
  DEBUG: bool = False
62
146
  ALLOWED_ORIGINS: list[str] = ["http://localhost:3000"]
63
147
 
64
- class Config:
65
- env_file = ".env"
148
+ @lru_cache
149
+ def get_settings() -> Settings:
150
+ return Settings() # raises ValidationError if env missing/invalid
151
+
152
+ settings = get_settings()
153
+ ```
154
+
155
+ ## SQLAlchemy 2.0 Async — `Mapped[]` style
156
+
157
+ ```python
158
+ # app/db/base.py
159
+ from sqlalchemy.orm import DeclarativeBase
160
+
161
+ class Base(DeclarativeBase):
162
+ pass
163
+
164
+ # app/db/models/user.py
165
+ from datetime import datetime
166
+ from uuid import UUID, uuid4
167
+ from sqlalchemy import String
168
+ from sqlalchemy.orm import Mapped, mapped_column
169
+
170
+ from app.db.base import Base
171
+
172
+ class User(Base):
173
+ __tablename__ = "users"
174
+
175
+ id: Mapped[UUID] = mapped_column(primary_key=True, default=uuid4)
176
+ email: Mapped[str] = mapped_column(String(320), unique=True, index=True)
177
+ name: Mapped[str] = mapped_column(String(255))
178
+ is_active: Mapped[bool] = mapped_column(default=True)
179
+ created_at: Mapped[datetime] = mapped_column(server_default="now()")
180
+
181
+ # app/db/session.py
182
+ from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
183
+ from app.core.config import settings
184
+
185
+ engine = create_async_engine(
186
+ settings.DATABASE_URL, # postgresql+asyncpg://… for PG; mysql+asyncmy://… for MySQL/MariaDB
187
+ pool_size=20,
188
+ max_overflow=10,
189
+ pool_pre_ping=True, # detect stale conns; mandatory in 2026
190
+ pool_recycle=1800, # recycle conns every 30 min
191
+ )
192
+ async_session = async_sessionmaker(engine, expire_on_commit=False)
193
+ ```
194
+
195
+ `Mapped[]` is mandatory in 2.0 — `Column()` without `Mapped[]` loses type inference. `expire_on_commit=False` prevents the "DetachedInstanceError" trap when you return ORM rows from an endpoint after the session closed.
196
+
197
+ ### Repository pattern
198
+
199
+ ```python
200
+ # app/services/user.py
201
+ from uuid import UUID
202
+ from sqlalchemy import select
203
+ from sqlalchemy.ext.asyncio import AsyncSession
204
+ from app.db.models.user import User
205
+
206
+ class UserService:
207
+ def __init__(self, db: AsyncSession) -> None:
208
+ self.db = db
209
+
210
+ async def get_by_id(self, id: UUID) -> User | None:
211
+ result = await self.db.execute(select(User).where(User.id == id))
212
+ return result.scalar_one_or_none()
213
+
214
+ async def create(self, data) -> User:
215
+ user = User(email=data.email, name=data.name)
216
+ self.db.add(user)
217
+ await self.db.commit()
218
+ await self.db.refresh(user)
219
+ return user
220
+ ```
221
+
222
+ ## Async / Sync — never mix
223
+
224
+ ```python
225
+ # WRONG — sync psycopg2 inside async def → freezes the event loop for ALL users
226
+ @router.get("/users")
227
+ async def list_users():
228
+ rows = sync_psycopg2.execute("SELECT * FROM users") # ❌
229
+ return rows
230
+
231
+ # CORRECT — async driver (asyncpg) inside async def
232
+ @router.get("/users")
233
+ async def list_users(db: AsyncSession = Depends(get_db)):
234
+ return (await db.execute(select(User))).scalars().all()
66
235
 
67
- settings = Settings()
236
+ # CORRECT — sync driver inside def → FastAPI runs in threadpool, doesn't block loop
237
+ @router.get("/legacy")
238
+ def list_legacy_users(): # plain def
239
+ return SyncSession().query(User).all()
68
240
  ```
69
241
 
242
+ If you're stuck with sync drivers (psycopg2, pymongo sync, mysqlclient), **declare the route as `def`, not `async def`**. FastAPI offloads it. Mixing the two paradigms inside `async def` is the #1 cause of "the API hangs under load".
243
+
70
244
  ## Middleware
71
245
 
72
246
  ```python
73
247
  from fastapi.middleware.cors import CORSMiddleware
248
+ from starlette.middleware.gzip import GZipMiddleware
249
+ from uuid import uuid4
74
250
 
251
+ app.add_middleware(GZipMiddleware, minimum_size=1024)
75
252
  app.add_middleware(
76
253
  CORSMiddleware,
77
- allow_origins=settings.ALLOWED_ORIGINS,
254
+ allow_origins=settings.ALLOWED_ORIGINS, # explicit list — never ["*"] with credentials
78
255
  allow_credentials=True,
79
- allow_methods=["*"],
80
- allow_headers=["*"],
256
+ allow_methods=["GET", "POST", "PATCH", "DELETE"],
257
+ allow_headers=["Authorization", "Content-Type", "X-Request-ID"],
81
258
  )
82
259
 
83
- # Custom middleware
84
260
  @app.middleware("http")
85
261
  async def add_request_id(request, call_next):
86
- request_id = str(uuid4())
87
- request.state.request_id = request_id
262
+ rid = request.headers.get("x-request-id", str(uuid4()))
263
+ request.state.request_id = rid
88
264
  response = await call_next(request)
89
- response.headers["X-Request-ID"] = request_id
265
+ response.headers["X-Request-ID"] = rid
90
266
  return response
91
267
  ```
92
268
 
93
- ## SQLAlchemy 2.0 Async
269
+ For the full security-headers / CORS / rate-limit pattern see `api-security-python`.
94
270
 
95
- ```python
96
- from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
271
+ ## Exception handlers — translate, don't catch
97
272
 
98
- engine = create_async_engine(settings.DATABASE_URL, pool_size=20, max_overflow=10)
99
- async_session = async_sessionmaker(engine, expire_on_commit=False)
273
+ ```python
274
+ # app/core/exceptions.py
275
+ from fastapi import HTTPException, Request, status
276
+ from fastapi.responses import JSONResponse
100
277
 
101
- # Repository pattern
102
- class UserRepository:
103
- def __init__(self, session):
104
- self.session = session
278
+ class NotFoundError(Exception):
279
+ def __init__(self, resource: str, id: str) -> None:
280
+ self.resource, self.id = resource, id
105
281
 
106
- async def get_by_id(self, id: UUID) -> User | None:
107
- result = await self.session.execute(
108
- select(User).where(User.id == id)
282
+ def register_exception_handlers(app: FastAPI) -> None:
283
+ @app.exception_handler(NotFoundError)
284
+ async def not_found(_: Request, exc: NotFoundError):
285
+ return JSONResponse(
286
+ status_code=404,
287
+ content={"type": "/problems/not-found",
288
+ "title": f"{exc.resource} not found",
289
+ "instance": exc.id},
109
290
  )
110
- return result.scalar_one_or_none()
111
291
  ```
112
292
 
113
- ## Production Deployment
293
+ Use `application/problem+json` shape — see the universal `openapi-design` skill.
114
294
 
115
- ```bash
116
- # uvicorn with gunicorn (production)
117
- gunicorn app.main:app -w 4 -k uvicorn.workers.UvicornWorker --bind 0.0.0.0:8000
295
+ ## Background work — pick by need
118
296
 
119
- # Docker
120
- FROM python:3.12-slim
297
+ | Tool | When |
298
+ |---|---|
299
+ | `BackgroundTasks` (built-in) | < 1 s, in-process, fire-and-forget after response |
300
+ | **ARQ** | Lightweight async tasks via Redis; preferred for FastAPI |
301
+ | **Dramatiq** | Actor-based, simpler than Celery |
302
+ | **Celery** | Distributed, complex retries/chains, multi-language workforce |
303
+ | **Temporal / Hatchet** | Long-running workflows (hours/days), need replay |
304
+
305
+ ## Production Deployment
306
+
307
+ ```dockerfile
308
+ # syntax=docker/dockerfile:1.7
309
+ FROM python:3.13-slim-bookworm AS builder
310
+ COPY --from=ghcr.io/astral-sh/uv:0.5 /uv /usr/local/bin/uv
121
311
  WORKDIR /app
122
- COPY requirements.txt .
123
- RUN pip install --no-cache-dir -r requirements.txt
312
+ COPY pyproject.toml uv.lock ./
313
+ RUN --mount=type=cache,target=/root/.cache/uv uv sync --frozen --no-dev --no-install-project
124
314
  COPY . .
125
- CMD ["gunicorn", "app.main:app", "-w", "4", "-k", "uvicorn.workers.UvicornWorker", "--bind", "0.0.0.0:8000"]
315
+ RUN --mount=type=cache,target=/root/.cache/uv uv sync --frozen --no-dev
316
+
317
+ FROM gcr.io/distroless/python3-debian12:nonroot
318
+ WORKDIR /app
319
+ COPY --from=builder --chown=nonroot:nonroot /app /app
320
+ USER nonroot
321
+ EXPOSE 8000
322
+ CMD ["/app/.venv/bin/uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "4"]
126
323
  ```
127
324
 
325
+ `uvicorn` is **production-ready** since the rewrite — `gunicorn` as a process manager is no longer required for most deployments. Use `--workers N` to fork. For Kubernetes, a single worker per pod + horizontal scaling is generally cleaner than multi-worker pods.
326
+
128
327
  ## FORBIDDEN
129
328
 
130
- 1. **`def` routes with async DB calls** — use `async def`
131
- 2. **Business logic in routes** — use services layer
132
- 3. **Hardcoded secrets** — use Pydantic Settings + `.env`
133
- 4. **No response_model**always specify for auto-docs
134
- 5. **Catching bare `Exception`** catch specific exceptions
329
+ | Pattern | Why |
330
+ |---|---|
331
+ | `@app.on_event("startup"\|"shutdown")` | Deprecated — use `lifespan` |
332
+ | `class Config:` inside Pydantic models | Pydantic V1 syntax use `model_config = ConfigDict(...)` |
333
+ | `from sqlalchemy import Column` for new models | 2.0 style is `mapped_column` + `Mapped[]` |
334
+ | Sync DB call inside `async def` route | Blocks event loop for everyone |
335
+ | `def` route doing async work | Run inside `async def` to use `await` |
336
+ | `Settings()` called per-request | Reads env each time — wrap in `@lru_cache` |
337
+ | `response_model=None` on a public endpoint | Loses validation + auto-docs |
338
+ | `allow_origins=["*"]` with `allow_credentials=True` | Browser silently blocks; auth breaks |
339
+ | Catching bare `Exception` in routes | Hides real bugs; use exception handlers |
340
+ | `engine` created at module scope without `pool_pre_ping=True` | Stale conns → 500s after pool idle |
341
+
342
+ ## See Also
343
+
344
+ - `pydantic-validation` — V2 schema patterns (Base/Create/Update/Response)
345
+ - `api-security-python` — security headers, CORS, JWT, rate limit, CSRF
346
+ - `async-patterns` — asyncio.timeout, TaskGroup, httpx best practice
347
+ - `pytest-testing` — async client fixture + DB rollback
348
+ - `_shared/skills/openapi-design` — `application/problem+json` shape
349
+ - `_shared/skills/observability` — request IDs, structured logs, GenAI semconv
@@ -1,11 +1,21 @@
1
1
  ---
2
2
  name: pydantic-validation
3
- version: 1.0.0
3
+ version: 2.0.0
4
+ description: "Runtime type-safety for Python boundaries with Pydantic V2 (Rust-backed pydantic-core, 5–50× faster than V1). Covers the Base/Create/Update/Response/InDB multi-model pattern, V2 validators (`field_validator` + `model_validator(mode='after')`), `ConfigDict(extra='forbid')` to block mass-assignment, `TypeAdapter` for non-BaseModel types (lists of dicts, primitives), discriminated unions for polymorphic payloads, JSON Schema export for OpenAPI, snake_case ↔ camelCase aliasing, and Pydantic Settings for env config (`SettingsConfigDict`). Invoke whenever you cross a trust boundary: HTTP body, query params, env vars, queue messages, ORM-to-API conversion, or LLM tool-call validation."
4
5
  ---
5
6
 
6
- # Pydantic Validation — Runtime Type Safety for Python
7
+ # Pydantic V2 — Runtime Validation at Boundaries
7
8
 
8
- **ALWAYS use Pydantic for API schemas, config, and data boundaries.**
9
+ **ALWAYS use Pydantic for API schemas, config, and any external data boundary.**
10
+
11
+ > Pydantic V1 is end-of-life — the V1 → V2 migration tool (`bump-pydantic`) handles the bulk. Anything below assumes V2 (`pydantic >= 2.x`, `pydantic-settings >= 2.x`).
12
+
13
+ ## Why V2
14
+
15
+ - Core rewritten in Rust → 5–50× faster than V1
16
+ - Built-in JSON parser → no `json.loads` first
17
+ - Cleaner discriminated unions, stricter coercion modes
18
+ - Native support in FastAPI, LangChain (LLM tool args), msgspec interop
9
19
 
10
20
  ## Multi-Model Pattern
11
21
 
@@ -69,17 +79,78 @@ class OrderCreate(BaseModel):
69
79
  ## Settings (env config)
70
80
 
71
81
  ```python
72
- from pydantic_settings import BaseSettings
82
+ from functools import lru_cache
83
+ from pydantic import Field
84
+ from pydantic_settings import BaseSettings, SettingsConfigDict
73
85
 
74
86
  class Settings(BaseSettings):
75
- DATABASE_URL: str
76
- SECRET_KEY: str
87
+ model_config = SettingsConfigDict(
88
+ env_file=".env",
89
+ env_file_encoding="utf-8",
90
+ extra="forbid", # unknown env vars → error early
91
+ env_nested_delimiter="__", # DB__HOST → settings.db.host
92
+ )
93
+
94
+ DATABASE_URL: str = Field(min_length=10)
95
+ SECRET_KEY: str = Field(min_length=32)
77
96
  DEBUG: bool = False
78
97
  REDIS_URL: str = "redis://localhost:6379"
79
98
 
80
- model_config = {"env_file": ".env", "env_file_encoding": "utf-8"}
99
+ @lru_cache
100
+ def get_settings() -> Settings:
101
+ return Settings() # validated once, raises on missing/invalid env
102
+
103
+ settings = get_settings()
104
+ ```
105
+
106
+ ## Discriminated Unions (polymorphic payloads)
107
+
108
+ ```python
109
+ from typing import Annotated, Literal
110
+ from pydantic import BaseModel, Field, TypeAdapter
111
+
112
+ class CardPayment(BaseModel):
113
+ type: Literal["card"]
114
+ last4: str
115
+ brand: str
116
+
117
+ class PixPayment(BaseModel):
118
+ type: Literal["pix"]
119
+ key: str
81
120
 
82
- settings = Settings()
121
+ # `type` field tells Pydantic which variant to instantiate — no isinstance dance
122
+ Payment = Annotated[CardPayment | PixPayment, Field(discriminator="type")]
123
+
124
+ class Order(BaseModel):
125
+ id: str
126
+ payment: Payment
127
+ ```
128
+
129
+ ## TypeAdapter — validate things that aren't BaseModel
130
+
131
+ When you receive a list of dicts, a primitive with constraints, or a payload you don't want to wrap in a class:
132
+
133
+ ```python
134
+ from pydantic import TypeAdapter, conint
135
+
136
+ PositiveInt = conint(gt=0)
137
+ positive = TypeAdapter(PositiveInt)
138
+ positive.validate_python(42) # 42
139
+ positive.validate_python(-1) # ValidationError
140
+
141
+ UserList = TypeAdapter(list[UserResponse])
142
+ users = UserList.validate_python(rows) # validates the whole list, not row-by-row
143
+ schema = UserList.json_schema() # OpenAPI / docs
144
+ ```
145
+
146
+ ## JSON Schema export (OpenAPI, LLM tool calls)
147
+
148
+ ```python
149
+ from app.schemas.user import UserCreate
150
+
151
+ UserCreate.model_json_schema()
152
+ # Use the result as `parameters` of an LLM tool definition (Anthropic, OpenAI),
153
+ # or merge into your OpenAPI spec.
83
154
  ```
84
155
 
85
156
  ## camelCase API (Python snake_case → JSON camelCase)
@@ -100,9 +171,33 @@ class UserResponse(ApiModel):
100
171
  created_at: datetime # JSON: "createdAt"
101
172
  ```
102
173
 
174
+ ## Strict mode (when you mean it)
175
+
176
+ ```python
177
+ from pydantic import BaseModel, ConfigDict
178
+
179
+ class StrictUser(BaseModel):
180
+ model_config = ConfigDict(strict=True) # NO coercion: "1" != 1, "true" != True
181
+ id: int
182
+ is_active: bool
183
+ ```
184
+
185
+ Strict mode is the right default for queue messages, internal RPC, and anything machine-to-machine. For HTTP endpoints, the default lax mode (with explicit `Field(...)` constraints) is more tolerant of legacy clients.
186
+
103
187
  ## FORBIDDEN
104
188
 
105
189
  1. **Raw dicts for API I/O** — always Pydantic models
106
- 2. **Skipping validation** — `model_validate()` not `dict()`
107
- 3. **Single model for everything** use Base/Create/Update/Response pattern
108
- 4. **No `from_attributes`** needed for ORM integration
190
+ 2. **`dict(model)` to serialize** — use `model.model_dump()` / `model_dump_json()`
191
+ 3. **Skipping validation** `model_validate(data)`, never `Model(**data)` for untrusted input
192
+ 4. **Single model for everything** Base/Create/Update/Response/InDB
193
+ 5. **Missing `from_attributes=True`** — needed when reading from ORM rows
194
+ 6. **Missing `extra="forbid"` on Settings or write payloads** — opens door to mass-assignment
195
+ 7. **Pydantic V1 syntax** (`@validator`, `class Config:`, `BaseSettings` from `pydantic`) — V1 is EOL, use V2 (`@field_validator`, `model_config = ConfigDict(...)`, `pydantic_settings.BaseSettings`)
196
+ 8. **Hand-rolled discriminator branching** (`if data["type"] == "card": ...`) — use `Annotated[Union[...], Field(discriminator=...)]`
197
+
198
+ ## See Also
199
+
200
+ - `fastapi-patterns` — request/response wiring
201
+ - `api-security-python` — `extra="forbid"` as anti-mass-assignment defense
202
+ - `_shared/skills/openapi-design` — schemas → OpenAPI 3.2
203
+