Contract-first backend with auth, idempotency and observability built in.
Read the skill at https://ai-skills-vault.pages.dev/skills/api-builder-fastapi.md and install it as a skill in your agent. Follow it exactly, including its checklists. Then ask me the questions it requires and do the work.
Most APIs fail on the same five things: unclear contracts, no authorisation model, no idempotency, no pagination discipline, and no observability. This skill closes all five before launch.
Write the OpenAPI shape first, or the resource table:
| Resource | Method | Path | Auth | Success | Errors |
|---|---|---|---|---|---|
| user | GET | /v1/users/{id} | bearer | 200 | 401, 403, 404 |
| user | POST | /v1/users | bearer + admin | 201 | 400, 401, 403, 409 |
| order | POST | /v1/orders | bearer | 201 | 400, 402, 409, 422 |
Rules that prevent rewrites: version in the path (/v1), plural nouns, no verbs in paths, snake_case JSON, ISO-8601 UTC timestamps, ids as UUID or ULID strings, money as integer minor units plus a currency code (never floats).
app/
main.py app factory, middleware, routers
core/ config, security, logging, errors
db/ engine, session, migrations
models/ SQLAlchemy models
schemas/ pydantic request/response models
api/v1/ routers per resource
services/ business logic - no HTTP here
tests/
Hard rule: routers validate and delegate; services contain logic; models touch the database. No SQL in a router, no Request object in a service.
from pydantic import BaseModel, EmailStr, Field
class UserCreate(BaseModel):
email: EmailStr
name: str = Field(min_length=1, max_length=80)
plan: Literal["free", "pro"] = "free"
One error envelope for the whole API, always:
{"error": {"code": "email_taken", "message": "That email is already registered.",
"field": "email", "request_id": "req_01H..."}}
request_id instead.code is a stable machine string; message is human and safe to show.def get_owned_order(order_id: str, user=Depends(current_user), db=Depends(get_db)) -> Order:
order = db.get(Order, order_id)
if not order:
raise HTTPException(404, "order_not_found")
if order.user_id != user.id and not user.is_admin:
raise HTTPException(403, "forbidden")
return order
Retry-After.Idempotency-Key on POST that creates money or state. Store key -> response for 24h; replay returns the same response.SELECT ... FOR UPDATE or an optimistic version column where concurrent updates matter.deleted_at when recovery or audit is needed; hard-delete for genuine privacy requests.@router.get("/orders")
def list_orders(cursor: str | None = None, limit: int = Query(20, le=100), ...):
limit at 100. Reject unknown sort fields against an allowlist - never interpolate into SQL.{"data": [...], "next_cursor": "..."}. Never return an unbounded array.create_all() in production.EXPLAIN.Target: the auth-and-write paths have 100% of their branches covered. Chasing a global coverage number is theatre.
request_id, user_id, route, status, duration_ms./healthz (liveness) and /readyz (dependencies reachable).* with credentials| Symptom | Cause | Fix |
|---|---|---|
| 500s under load | Pool exhaustion / N+1 queries | Pool sizing, eager loading, indexes |
| Duplicate charges | No idempotency key | Add key + unique constraint |
| Slow list endpoint | OFFSET on a big table | Cursor pagination |
| Data leak | Missing ownership check | Central ownership dependency, test it |
| Broken clients after a change | Unversioned breaking change | /v2, deprecate with a sunset header |
Read the skill at the URL above. Start with the Stage 1 resource table for my domain and get my approval. Then scaffold the layout, write pydantic schemas, services with the logic, routers that only delegate, one error envelope, an ownership-check dependency, cursor pagination, Alembic migrations and tests including auth failure cases. Finish with the Stage 10 deploy checklist and tell me honestly which items are not done.