python-fastapi-stack
Skill0 starsChanged 25 days agoMITView on GitHub
FastAPI conventions for REST APIs: routers, Pydantic v2 schemas, dependency injection, error handling, pagination, and project structure. Trigger: When writing any router, endpoint, schema, or dependency in a FastAPI project.
- Reads credentials
In this repository219 other files it gives its agents.
- .agents/AGENTS.mdAGENTS.md
- AGENTS.mdAGENTS.md
- .claude/CLAUDE.mdCLAUDE.md
- CLAUDE.mdCLAUDE.md
- .cursor/rules/behavioral-discipline.mdcCursor rule
- .cursor/rules/context-budget.mdcCursor rule
- .cursor/rules/orchestration.mdcCursor rule
- .cursor/rules/yagni-stdlib.mdcCursor rule
- androidSkill
- android-ui-designSkill
- api-versioningSkill
- astroSkill
- auth-token-inspectorSkill
- backlogSkill
- cc-api-contractSkill
- cc-db-migrationSkill
- cc-featureSkill
- cc-fixSkill
- cc-pagespeedSkill
- cc-refactorSkill
- cc-reviewSkill
- cc-spec-mutationSkill
- cc-tdd-cycleSkill
- cc-test-planSkill
- code-reviewSkill
- commitSkill
- conductor-setupSkill
- councilSkill
- database-migrationsSkill
- django-ormSkill
- django-testingSkill
- django-uvSkill
- drizzle-schema-architectSkill
- evaluationSkill
- fastapi-pydantic-strictSkill
- find-skillsSkill
- jpa-nplusone-detectorSkill
- jpa-postgresSkill
- laravel-specialistSkill
- livewire-alpine-bridgeSkill
- multi-agent-orchestrationSkill
- nextjs-typescriptSkill
- openspecSkill
- pagespeed-insightsSkill
- pagespeed-perfSkill
- php-proSkill
- python-django-stackSkill
- pythonSkill
- rust-asyncSkill
- rust-engineeringSkill
- rust-sqlxSkill
- security-ai-llmSkill
- security-blue-teamSkill
- security-cloudSkill
- security-cryptoSkill
- security-exploit-devSkill
- security-grcSkill
- security-incident-responseSkill
- security-log-analysisSkill
- security-malware-analysisSkill
- The first 60 shown.
The file
We split this skill into the rules it gives an agent
8 rules. About 0 are common, 2 are distinctive, and 6 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈2
Distinctive · estimate
This author's own choices. Worth a look.
6
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Distinctive: line 82
Never write business logic directly in an endpoint function.
- Very specific: line 115
Use lifespan — never @app.on_event("startup") (deprecated).
- Very specific: line 137
Never hardcode secrets. Never import os.environ directly in app code — always go through settings.
- Very specific: line 248
Never return ORM model objects directly from endpoints — always go through a Read schema.
- Distinctive: line 270
Rule: commit in the dependency, not in the service.
- Very specific: line 321
Never return 200 for a creation — use 201.
- Very specific: line 380
Never mix async def with synchronous blocking calls (e.g., requests.get).
- Very specific: line 381
Use httpx.AsyncClient for external HTTP.
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
When to Use
- Writing any new endpoint, router, or schema
- Designing the structure of a new FastAPI feature
- Adding error handling or validation
- Implementing pagination or filtering
- Configuring dependencies (DB session, auth, settings)
Project Structure
src/
├── main.py # App factory, lifespan, router inclusion
├── config.py # Settings via pydantic-settings
├── dependencies.py # Shared Depends() functions (db, auth)
├── routers/
│ ├── __init__.py
│ ├── products.py
│ └── orders.py
├── schemas/
│ ├── __init__.py
│ ├── product.py # ProductCreate, ProductRead, ProductUpdate
│ └── order.py
├── models/ # SQLAlchemy models (see sqlalchemy skill)
│ ├── __init__.py
│ └── product.py
├── services/ # Business logic — never in routers
│ └── product.py
└── tests/
├── conftest.py
└── test_products.pyRule: Routers delegate to services. Services contain all business logic. Never write business logic directly in an endpoint function.
Application Factory
# main.py
from contextlib import asynccontextmanager
from fastapi import FastAPI
from src.routers import products, orders
from src.config import settings
@asynccontextmanager
async def lifespan(app: FastAPI):
# startup
yield
# shutdown
def create_app() -> FastAPI:
app = FastAPI(
title=settings.app_name,
version=settings.app_version,
lifespan=lifespan,
)
app.include_router(products.router)
app.include_router(orders.router)
return app
app = create_app()Use lifespan — never @app.on_event("startup") (deprecated).
Settings
# config.py
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
app_name: str = "My API"
app_version: str = "0.1.0"
database_url: str
secret_key: str
debug: bool = False
settings = Settings()Never hardcode secrets. Never import os.environ directly in app code — always
go through settings.
Routers
# routers/products.py
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from src.dependencies import get_db
from src.schemas.product import ProductCreate, ProductRead, ProductListResponse
from src.services.product import ProductService
router = APIRouter(prefix="/products", tags=["products"])
@router.get("/", response_model=ProductListResponse)
async def list_products(
offset: int = 0,
limit: int = 20,
db: AsyncSession = Depends(get_db),
):
return await ProductService.list(db, offset=offset, limit=limit)
@router.get("/{product_id}", response_model=ProductRead)
async def get_product(product_id: int, db: AsyncSession = Depends(get_db)):
product = await ProductService.get_or_404(db, product_id)
return product
@router.post("/", response_model=ProductRead, status_code=status.HTTP_201_CREATED)
async def create_product(payload: ProductCreate, db: AsyncSession = Depends(get_db)):
return await ProductService.create(db, payload)
@router.patch("/{product_id}", response_model=ProductRead)
async def update_product(
product_id: int,
payload: ProductUpdate,
db: AsyncSession = Depends(get_db),
):
return await ProductService.update(db, product_id, payload)
@router.delete("/{product_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_product(product_id: int, db: AsyncSession = Depends(get_db)):
await ProductService.delete(db, product_id)HTTP method rules:
GET— read, never mutatesPOST— create →201PATCH— partial update →200PUT— full replace →200DELETE— remove →204(no body)
Schemas (Pydantic v2)
# schemas/product.py
from datetime import datetime
from pydantic import BaseModel, Field, model_validator
class ProductBase(BaseModel):
name: str = Field(..., min_length=1, max_length=255)
price: float = Field(..., gt=0)
stock: int = Field(default=0, ge=0)
is_active: bool = True
class ProductCreate(ProductBase):
pass
class ProductUpdate(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=255)
price: float | None = Field(default=None, gt=0)
stock: int | None = Field(default=None, ge=0)
is_active: bool | None = None
@model_validator(mode="after")
def at_least_one_field(self) -> "ProductUpdate":
if all(v is None for v in self.model_dump().values()):
raise ValueError("at least one field must be provided")
return self
class ProductRead(ProductBase):
model_config = {"from_attributes": True}
id: int
created_at: datetime
updated_at: datetime
class ProductListResponse(BaseModel):
results: list[ProductRead]
total: int
has_more: boolSchema split rule:
Base— shared fieldsCreate— input for POST (no id, no timestamps)Update— all fields optional (PATCH semantics)Read— output withfrom_attributes = True(ORM → schema)
Never return ORM model objects directly from endpoints — always go through a
Read schema.
Dependency Injection
# dependencies.py
from collections.abc import AsyncGenerator
from sqlalchemy.ext.asyncio import AsyncSession
from src.db import async_session_factory
async def get_db() -> AsyncGenerator[AsyncSession, None]:
async with async_session_factory() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raiseRule: commit in the dependency, not in the service. Services are commit-agnostic. The dependency owns the transaction boundary.
Error Handling
# Raise HTTPException in services for expected errors
from fastapi import HTTPException, status
class ProductService:
@staticmethod
async def get_or_404(db: AsyncSession, product_id: int) -> Product:
product = await db.get(Product, product_id)
if product is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Product {product_id} not found",
)
return product# main.py — global exception handlers for unexpected errors
from fastapi import Request
from fastapi.responses import JSONResponse
@app.exception_handler(Exception)
async def unhandled_exception_handler(request: Request, exc: Exception) -> JSONResponse:
return JSONResponse(
status_code=500,
content={"detail": "Internal server error"},
)Status code table:
| Code | When |
| 200 | Successful GET, PATCH, PUT |
| 201 | Successful POST (resource created) |
| 204 | Successful DELETE (no body) |
| 400 | Validation error, bad input |
| 401 | Not authenticated |
| 403 | Authenticated but not authorized |
| 404 | Resource not found |
| 409 | Conflict (duplicate, constraint violation) |
| 422 | Pydantic validation error (automatic) |
| 500 | Unhandled server error |
Never return 200 for a creation — use 201. Never return a body for 204.
Pagination
Offset/limit — no page numbers. Default 20, max 100:
# schemas
class ProductListResponse(BaseModel):
results: list[ProductRead]
total: int
has_more: bool
# service
class ProductService:
@staticmethod
async def list(
db: AsyncSession,
offset: int = 0,
limit: int = 20,
) -> ProductListResponse:
limit = min(limit, 100)
count_result = await db.execute(select(func.count()).select_from(Product))
total = count_result.scalar_one()
result = await db.execute(
select(Product).offset(offset).limit(limit).order_by(Product.id)
)
items = result.scalars().all()
return ProductListResponse(
results=items,
total=total,
has_more=(offset + limit) < total,
)Async vs Sync Endpoints
| Use async | Use sync |
| DB queries (AsyncSession) | CPU-bound transformations |
| External HTTP calls (httpx.AsyncClient) | Pure in-memory logic |
Any await inside the handler | No I/O at all |
# async — has I/O
@router.get("/{id}")
async def get_product(id: int, db: AsyncSession = Depends(get_db)):
return await ProductService.get_or_404(db, id)
# sync — pure computation, no I/O
@router.get("/health")
def health_check():
return {"status": "ok"}Never mix async def with synchronous blocking calls (e.g., requests.get).
Use httpx.AsyncClient for external HTTP.
Naming Conventions
| Concern | Convention | Example |
| Router files | snake_case.py | product_variants.py |
| Schema classes | PascalCase + suffix | ProductCreate, ProductRead |
| Endpoint functions | verb_resource | list_products, create_order |
| Service methods | @staticmethod, verb_noun | get_or_404, create, list |
| Dependency functions | get_* | get_db, get_current_user |
| Router prefix | lowercase, plural, kebab | /product-variants |
| Tags | lowercase, plural | ["product-variants"] |
Service Layer
Business logic lives in services/. Services are classes with @staticmethod
only:
# services/product.py
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from fastapi import HTTPException, status
from src.models.product import Product
from src.schemas.product import ProductCreate, ProductUpdate, ProductListResponse
class ProductService:
@staticmethod
async def get_or_404(db: AsyncSession, product_id: int) -> Product:
product = await db.get(Product, product_id)
if product is None:
raise HTTPException(status_code=404, detail=f"Product {product_id} not found")
return product
@staticmethod
async def create(db: AsyncSession, payload: ProductCreate) -> Product:
product = Product(**payload.model_dump())
db.add(product)
await db.flush() # get the ID without committing
await db.refresh(product)
return product
@staticmethod
async def update(db: AsyncSession, product_id: int, payload: ProductUpdate) -> Product:
product = await ProductService.get_or_404(db, product_id)
for field, value in payload.model_dump(exclude_unset=True).items():
setattr(product, field, value)
await db.flush()
await db.refresh(product)
return product
@staticmethod
async def delete(db: AsyncSession, product_id: int) -> None:
product = await ProductService.get_or_404(db, product_id)
await db.delete(product)Rules:
db.flush()to get the ID without committing — commit is inget_dbdb.refresh(product)to reload relationships after flushpayload.model_dump(exclude_unset=True)for PATCH — only update provided fields
Commands
uvicorn src.main:app --reload # dev server
uvicorn src.main:app --host 0.0.0.0 # production-like
pytest tests/ -v # test suite
ruff check . # lint
ruff format . # formatResources
- FastAPI docs: https://fastapi.tiangolo.com/
- Pydantic v2 docs: https://docs.pydantic.dev/latest/
- pydantic-settings: https://docs.pydantic.dev/latest/concepts/pydantic_settings/
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

