FastAPI Services skill for Codex
Thin routes, typed dependencies, no blocked loop. Codex lays out FastAPI apps as routers, schemas and services, creates engines and clients in lifespan, and injects sessions, settings and the current user through Annotated dependencies. It chooses async def or def by what the endpoint calls, prevents MissingGreenlet with eager loading and expire_on_commit=False, and tests through httpx AsyncClient with dependency_overrides rather than monkeypatching.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- fastapi-services Installs to ~/.agents/skills/fastapi-services/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install fastapi-services The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project fastapi-services Installs to .agents/skills/fastapi-services/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/fastapi-services.zipunzip -o fastapi-services.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) fastapi-services PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $fastapi-services, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Builds FastAPI services with clear layering - APIRouter per feature, Annotated dependencies for sessions, auth and settings, Pydantic v2 request and response models, lifespan-managed resources, async SQLAlchemy 2.0 sessions without lazy-load errors, consistent exception handlers, and httpx AsyncClient tests with dependency_overrides. Use when creating or extending a FastAPI app, adding endpoints or auth dependencies, fixing MissingGreenlet or event-loop blocking issues, or making response models stop leaking fields.
Use it when
- Creating or extending a FastAPI service
- Adding auth, settings or database session dependencies
- Async SQLAlchemy raises MissingGreenlet or lazy-load errors
- Responses expose fields they should not, or latency spikes from blocking calls
Use something else when
- The question is purely about Pydantic validators and serialization: use pydantic-v2
- You need ORM query design beyond the request layer: use sqlalchemy-2
How to check the result
pytest passes with dependency overrides, /openapi.json shows output models without internal fields, and protected routes return 401 without a token.
The mistake it prevents
Calling a blocking library such as requests or psycopg2 inside an async def endpoint, which stalls every concurrent request on that worker.
Done well means: routes are thin (parse, authorize, call a service, return a response
model), shared resources are created once in lifespan, nothing blocking runs inside
async def, every endpoint declares a response model that excludes internal fields,
and tests replace dependencies instead of patching modules.
Inspect first
- Read
AGENTS.mdandpyproject.toml: FastAPI and Pydantic versions (v2 assumed), package manager (uv, poetry, pip), DB stack (SQLAlchemy async, SQLModel, asyncpg directly), test runner. - Find the app and routers:
rg -n "FastAPI\(|APIRouter\(|include_router" .. - Find dependencies and settings:
rg -n "Depends\(|BaseSettings|lifespan" .. - Check how it runs:
fastapi run,uvicorn ..., gunicorn config, Dockerfile.
Layout
app/
main.py # create_app(), lifespan, include_router, exception handlers
config.py # Settings (pydantic-settings)
db.py # engine, async_sessionmaker, get_session dependency
deps.py # SessionDep, CurrentUser, SettingsDep (Annotated aliases)
orders/
router.py # HTTP only
schemas.py # Pydantic models: OrderCreate, OrderRead
service.py # business logic, takes a session and plain values
models.py # SQLAlchemy ORM models
tests/
Settings and lifespan
# config.py
from functools import lru_cache
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
database_url: str
jwt_secret: str
cors_origins: list[str] = []
@lru_cache
def get_settings() -> Settings:
return Settings()
# main.py
from contextlib import asynccontextmanager
from fastapi import FastAPI
@asynccontextmanager
async def lifespan(app: FastAPI):
settings = get_settings()
app.state.engine = create_async_engine(settings.database_url, pool_pre_ping=True)
app.state.sessionmaker = async_sessionmaker(app.state.engine, expire_on_commit=False)
yield
await app.state.engine.dispose()
def create_app() -> FastAPI:
app = FastAPI(lifespan=lifespan)
app.include_router(orders_router, prefix="/orders", tags=["orders"])
register_exception_handlers(app)
return app
app = create_app()
@app.on_event("startup") is deprecated; use lifespan. Missing settings now fail at
boot instead of at the first request.
Dependencies
# db.py / deps.py
from typing import Annotated, AsyncIterator
from fastapi import Depends, HTTPException, Request, status
from sqlalchemy.ext.asyncio import AsyncSession
async def get_session(request: Request) -> AsyncIterator[AsyncSession]:
async with request.app.state.sessionmaker() as session:
yield session
SessionDep = Annotated[AsyncSession, Depends(get_session)]
async def get_current_user(session: SessionDep, token: Annotated[str, Depends(oauth2_scheme)]) -> User:
user = await authenticate(session, token)
if user is None:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid token",
headers={"WWW-Authenticate": "Bearer"})
return user
CurrentUser = Annotated[User, Depends(get_current_user)]
- Dependencies are cached per request:
get_sessionused by both the route andget_current_useryields one session. - Router-wide auth:
APIRouter(dependencies=[Depends(get_current_user)]). - Code after
yieldin a dependency runs after the response is sent by default (it supports streaming responses). Do not hand that session toBackgroundTasks; open a fresh session inside the task.
Routes and models
# orders/schemas.py
from pydantic import BaseModel, ConfigDict, Field
class OrderCreate(BaseModel):
model_config = ConfigDict(extra="forbid")
sku: str = Field(min_length=1, max_length=64)
qty: int = Field(gt=0, le=1000)
class OrderRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
sku: str
qty: int
status: str
# orders/router.py
from fastapi import APIRouter, HTTPException, status
router = APIRouter()
@router.post("", response_model=OrderRead, status_code=status.HTTP_201_CREATED)
async def create_order(body: OrderCreate, session: SessionDep, user: CurrentUser):
order = await service.create_order(session, user_id=user.id, data=body)
await session.commit()
return order
@router.get("/{order_id}", response_model=OrderRead)
async def get_order(order_id: int, session: SessionDep, user: CurrentUser):
order = await service.get_for_user(session, order_id, user.id)
if order is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Order not found")
return order
- Always set
response_model(or a return annotation). Returning an ORM object without one serializes whatever is reachable, including fields you did not mean to expose. - Separate input and output models; never reuse the ORM model or one model with
optional
passwordfor both directions. - Scope queries by owner in the service (
WHERE user_id = :uid) and return 404, not 403, for other users’ objects unless existence is public.
async def or def
| Endpoint does | Declare as |
|---|---|
| Awaits async libraries (asyncpg, httpx.AsyncClient, aioboto3) | async def |
| Calls blocking libraries (requests, psycopg2, boto3, heavy CPU) | def (runs in the threadpool) |
| Mix | async def + await run_in_threadpool(fn, ...) or asyncio.to_thread for the blocking part |
One time.sleep or requests.get inside async def stalls every request on that worker.
CPU-heavy work belongs in a job queue: see $background-jobs.
Async SQLAlchemy rules
expire_on_commit=False, or attribute access aftercommit()triggers a lazy load and raisesMissingGreenlet.- Load relationships explicitly:
select(Order).options(selectinload(Order.items)). Settinglazy="raise"on relationships turns accidental lazy loads into clear errors. - One
AsyncSessionper request; never share a session across concurrent tasks (asyncio.gatherover the same session fails). - Migrations with Alembic (
alembic revision --autogenerate -m ..., review the script,alembic upgrade head). For deeper ORM questions use$sqlalchemy-2.
Errors
class DomainError(Exception):
def __init__(self, code: str, message: str, status_code: int = 400):
self.code, self.message, self.status_code = code, message, status_code
def register_exception_handlers(app: FastAPI) -> None:
@app.exception_handler(DomainError)
async def domain_error(_: Request, exc: DomainError):
return JSONResponse({"error": {"code": exc.code, "message": exc.message}}, exc.status_code)
Services raise domain errors; routers or handlers map them to HTTP. Validation errors
return 422 with Pydantic’s error list; customize via a RequestValidationError handler
only if clients need a different shape.
Testing
import pytest
from httpx import ASGITransport, AsyncClient
@pytest.fixture
async def client(session):
app = create_app()
app.dependency_overrides[get_session] = lambda: session
app.dependency_overrides[get_current_user] = lambda: User(id=1, email="[email protected]")
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
yield c
async def test_create_order_rejects_zero_qty(client):
r = await client.post("/orders", json={"sku": "A1", "qty": 0})
assert r.status_code == 422
ASGITransport does not run lifespan; create resources in fixtures or wrap the app
with asgi-lifespan’s LifespanManager if the test needs them. Use pytest-asyncio or
anyio markers as the project already does. Run the DB in a transaction rolled back
per test, or use testcontainers for Postgres.
Running in production
fastapi run app/main.py --workers 4oruvicorn app.main:app --workers 4. Behind a proxy, enable proxy headers and restrict--forwarded-allow-ipsto the proxy.- Pool size x workers x replicas must fit the database’s connection limit.
- Disable docs in production only if policy requires it:
FastAPI(docs_url=None, redoc_url=None).
Verification
uv run pytest -q # or the command in AGENTS.md
uv run ruff check . && uv run mypy app # if configured
uv run fastapi dev app/main.py # then open /docs and exercise new routes
Check the generated OpenAPI (/openapi.json) shows the intended response model with no
internal fields, and that an unauthenticated call to a protected route returns 401.
Gotchas
- Returning a
Response/JSONResponsedirectly bypassesresponse_modelfiltering and validation; return data and let FastAPI serialize it unless you need custom headers. BackgroundTasksrun in the same worker after the response; a crash loses them. Use a real queue for anything that must happen.Depends()on a class instantiates it per request; expensive clients belong in lifespan.- Path order matters: declare
/orders/exportbefore/orders/{order_id}.
Output template
## FastAPI change: <summary>
- Endpoints: <method path -> response model, status codes>
- Dependencies: <added/changed>
- Models: <input/output schemas>
- DB: <queries, eager loads, migration id>
- Tests: <cases, overrides used> What the zip contains
SKILL.md plus the interface file below. Nothing runs on install.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "FastAPI Services"
short_description: "Thin routes, typed dependencies, no blocked loop"
default_prompt: "$fastapi-services Add an orders router to our FastAPI app with auth, Pydantic models and tests" About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.