FastAPI es la respuesta por defecto cuando un equipo Python necesita una API HTTP tipada con OpenAPI gratis. El framework se queda pequeño: declara rutas, valida cuerpos con Pydantic, saca el setup compartido por dependencias y prefiere async def donde hay espera de I/O. Este post es el camino que uso al levantar un servicio que otros equipos llamarán el mes que viene.
Te vas con una API mínima de items: crear, leer, listar, borrar, más validación, una dependencia de sesión DB compartida, handlers async y un par de tests que fallan de verdad cuando rompes el contrato.
Instalación y una app mínima
python -m venv .venv
source .venv/bin/activate
pip install "fastapi[standard]" httpx
fastapi[standard] trae Uvicorn y los extras habituales. httpx es lo que usa el TestClient de Starlette por debajo.
# main.py
from fastapi import FastAPI
app = FastAPI(title="Items API", version="0.1.0")
@app.get("/health")
def health() -> dict[str, str]:
return {"status": "ok"}
Ejecuta:
uvicorn main:app --reload
Abre http://127.0.0.1:8000/docs. Ese esquema interactivo no es un juguete. Producto y móvil lo usan como contrato mientras tú aún cambias nombres de campos.
Routing: path, query y códigos de estado
Los path parameters se convierten en argumentos tipados. Los query parameters son los opcionales que no van en el path.
from enum import Enum
from fastapi import FastAPI, HTTPException, Query, status
app = FastAPI()
class SortOrder(str, Enum):
asc = "asc"
desc = "desc"
@app.get("/items/{item_id}")
def get_item(item_id: int) -> dict:
if item_id < 1:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Item not found",
)
return {"id": item_id, "name": f"item-{item_id}"}
@app.get("/items")
def list_items(
q: str | None = Query(default=None, min_length=1, max_length=50),
limit: int = Query(default=20, ge=1, le=100),
order: SortOrder = SortOrder.asc,
) -> dict:
return {"q": q, "limit": limit, "order": order}
Hábitos que mantienen las APIs aburridas en el buen sentido:
- Usa enums para conjuntos cerrados (
order=asc|desc). OpenAPI los muestra como desplegables. - Pon límites a los query params (
ge,le,min_length). Los clientes malos fallan en el borde. - Lanza
HTTPExceptionpara errores de cliente esperados. Los bugs inesperados deben seguir en 500 para que los veas. - Prefiere códigos de estado explícitos al crear (
201) y borrar (204) en lugar de devolver siempre200.
from fastapi import Response
@app.delete("/items/{item_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_item(item_id: int) -> Response:
# delete from store...
return Response(status_code=status.HTTP_204_NO_CONTENT)
Los routers mantienen los módulos sanos cuando pasas de una docena de endpoints:
# routers/items.py
from fastapi import APIRouter
router = APIRouter(prefix="/items", tags=["items"])
@router.get("/{item_id}")
def get_item(item_id: int) -> dict:
return {"id": item_id}
# main.py
from routers import items
app.include_router(items.router)
Pydantic: formas de request y response
FastAPI lee las type hints. Los modelos de Pydantic v2 definen el formato del cable y rechazan basura antes de que corra tu handler.
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field, field_validator
class ItemCreate(BaseModel):
name: str = Field(min_length=1, max_length=80)
price: float = Field(gt=0, description="Price in USD")
tags: list[str] = Field(default_factory=list)
@field_validator("name")
@classmethod
def name_not_blank(cls, v: str) -> str:
cleaned = v.strip()
if not cleaned:
raise ValueError("name cannot be blank")
return cleaned
class ItemOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
name: str
price: float
tags: list[str]
created_at: datetime
Enlázalos a las rutas:
from fastapi import FastAPI, status
app = FastAPI()
_store: dict[int, ItemOut] = {}
_next_id = 1
@app.post("/items", response_model=ItemOut, status_code=status.HTTP_201_CREATED)
def create_item(body: ItemCreate) -> ItemOut:
global _next_id
item = ItemOut(
id=_next_id,
name=body.name,
price=body.price,
tags=body.tags,
created_at=datetime.utcnow(),
)
_store[_next_id] = item
_next_id += 1
return item
@app.get("/items/{item_id}", response_model=ItemOut)
def get_item(item_id: int) -> ItemOut:
item = _store.get(item_id)
if item is None:
raise HTTPException(status_code=404, detail="Item not found")
return item
Por qué este patrón se queda:
ItemCreatevsItemOut: el cliente nunca envíaidnicreated_at. Tú no los aceptas por accidente.response_model: recorta atributos de más y documenta la forma de éxito.- Los validators atrapan reglas de producto que los tipos crudos no pueden (
strip, nombre no vacío).
Un JSON inválido recibe un 422 claro con errores por campo. No implementas eso a mano.
Para actualizaciones parciales, usa un modelo aparte con campos opcionales (o los patrones de Pydantic para PATCH). No reutilices ItemCreate fingiendo que una clave ausente significa "dejar igual".
Dependencias: setup compartido sin globals por todas partes
Las dependencias son funciones que FastAPI llama antes del handler. Usos clásicos: sesiones de DB, usuario actual, API keys, feature flags.
from collections.abc import Generator
from typing import Annotated
from fastapi import Depends, Header, HTTPException, status
def get_db() -> Generator[dict, None, None]:
# stand-in for a real session
db: dict = {"connected": True}
try:
yield db
finally:
db["connected"] = False
def require_api_key(x_api_key: Annotated[str | None, Header()] = None) -> str:
if x_api_key != "secret-dev-key":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or missing API key",
)
return x_api_key
DbDep = Annotated[dict, Depends(get_db)]
AuthDep = Annotated[str, Depends(require_api_key)]
@app.get("/private/items")
def private_list(db: DbDep, _: AuthDep) -> dict:
return {"db": db["connected"], "items": list(_store.values())}
Notas de uso en producción:
- Prefiere dependencias con
yieldpara lo que debe limpiarse (sesiones, conexiones). - Compón deps pequeñas (
get_current_userdepende deget_token) en lugar de una función gigante. - Los alias con
Annotated[..., Depends(...)]acortan las firmas de los handlers. - En tests, sobrescribe dependencias para no pegarle a Postgres real ni a auth real:
app.dependency_overrides[get_db] = lambda: {"connected": True}
Limpia los overrides al terminar el módulo de tests o filtrarás fakes a otros casos.
Async: cuándo ayuda async def (y cuándo no)
FastAPI ejecuta handlers sync en un thread pool y los async en el event loop. Usa async def cuando el handler espera librerías de I/O de verdad (cliente async de httpx, asyncpg, motor, aiobotocore). Un bucle de CPU puro dentro de async def sigue bloqueando el loop para todos.
import httpx
from fastapi import FastAPI
app = FastAPI()
@app.get("/proxy/todos/{todo_id}")
async def proxy_todo(todo_id: int) -> dict:
async with httpx.AsyncClient(timeout=5.0) as client:
r = await client.get(
f"https://jsonplaceholder.typicode.com/todos/{todo_id}"
)
r.raise_for_status()
return r.json()
Reglas prácticas:
- Async de punta a punta en un camino que ya es async (no llames
requestsbloqueante dentro deasync def). - Deja SQLAlchemy sync o SDKs sync en handlers
def, o usa un driver async de verdad. - Los timeouts van en los clientes. Un await sin tope es un incidente de producción con mejores stack traces.
No hace falta que toda ruta sea async el día uno. Convierte las pesadas de I/O cuando midas contención.
Tests básicos con TestClient
Empieza simple: golpea la app en proceso. Sin puerto libre, sin red real.
# test_items.py
from fastapi.testclient import TestClient
from main import app
client = TestClient(app)
def test_health():
r = client.get("/health")
assert r.status_code == 200
assert r.json() == {"status": "ok"}
def test_create_and_get_item():
payload = {"name": "Widget", "price": 9.5, "tags": ["hw"]}
created = client.post("/items", json=payload)
assert created.status_code == 201
body = created.json()
assert body["name"] == "Widget"
assert "id" in body
got = client.get(f"/items/{body['id']}")
assert got.status_code == 200
assert got.json()["price"] == 9.5
def test_create_rejects_blank_name():
r = client.post("/items", json={"name": " ", "price": 1.0})
assert r.status_code == 422
def test_private_requires_key():
r = client.get("/private/items")
assert r.status_code == 401
ok = client.get(
"/private/items",
headers={"X-API-Key": "secret-dev-key"},
)
assert ok.status_code == 200
Ejecuta con:
pip install pytest
pytest -q
Patrones que escalan más allá del primer archivo:
- Helpers factory para payloads y no copiar diez blobs JSON.
- Override de
get_dbcon un store en memoria por test. - Afirma código de estado y unos pocos campos, no todo el dump de OpenAPI.
- Prefiere un comportamiento por nombre de test (
test_create_rejects_blank_name) frente a un "test_items_flow" de 40 líneas.
Cuando necesites tests async de verdad contra clientes solo async, usa httpx.AsyncClient con ASGITransport y pytest-asyncio. TestClient cubre la mayoría de checks REST sin esa ceremonia.
Una forma compacta que se publica
Junta las piezas mentalmente:
| Capa | Responsabilidad |
|---|---|
| Router | Verbos HTTP, paths, códigos de estado |
| Modelos Pydantic | Validar entrada, dar forma a la salida |
| Dependencias | Auth, DB, config |
| Service / dominio | Reglas de negocio (sácalas del body de la ruta cuando crezcan) |
| Tests | Contrato en el borde HTTP |
Estructura del día uno que envejece bien:
app/
main.py # FastAPI(), include routers
deps.py # get_db, get_current_user
models/
item.py # ItemCreate, ItemOut
routers/
items.py
services/
items.py # optional once logic spreads
tests/
test_items.py
No inventes un hexágono de carpetas para tres endpoints. Crece cuando el handler empiece a conocer SQL, auth y email a la vez.
Errores comunes
- Devolver objetos ORM sin
response_modelni DTO y filtrar columnas que no querías exponer. - Un solo modelo Pydantic gigante para create, update y response. Sepáralos.
- I/O bloqueante en
async def(requestssync, trabajo pesado de archivos) y preguntarte por qué la latencia se cae bajo carga. - Auth solo en algunas rutas porque un router nuevo olvidó la dependencia. Prefiere
dependencies=[Depends(...)]a nivel de router para grupos enteros. - Sin tests en caminos 422. La validación es parte de tu API pública.
Qué hacer después
Si empiezas esta semana: crea la ruta health, un recurso con ItemCreate / ItemOut, una dependencia de API key por header y cuatro tests (camino feliz, 404, 422, 401). Añade async solo donde llames HTTP externo o un cliente de DB async.
La documentación oficial sigue siendo la mejor referencia para bordes (WebSockets, background tasks, middleware): fastapi.tiangolo.com. Para el resto, el código de arriba basta para publicar un primer servicio sin copiar un starter que no entiendes.
