De cero a una API en producción con Python: caso Tienda API
Una API para una tienda en línea. Todo el taller gira alrededor de estos recursos.
id, nombre, precio, categoría, stock.
productos, cantidad, cliente, total.
credenciales, perfil, permisos.
/productos
POST/productos
PUT/productos/{id}
DELETE/productos/{id}
Qué es una API REST y por qué el mundo habla HTTP
Interfaz que permite que dos programas se comuniquen: el cliente pide, el servidor responde.
REpresentational State Transfer: reglas para diseñar esos servicios de forma uniforme sobre HTTP.
Todo se modela como un recurso: una entidad con nombre. Cada recurso vive en una URL única.
productos), no con acciones (obtenerProductos).La acción no va en la URL: va en el método HTTP.
/productos, distinto verbo: GET lo lista, POST lo crea.Cada petición lleva toda la información necesaria (incluida la identidad). El servidor no recuerda al cliente entre peticiones.
Los datos viajan en un formato estándar: application/json. Fácil de leer por humanos y máquinas.
{
"id": 42, "nombre": "Mouse", "precio": 25.9, "en_stock": true
}
HTTP responde con un número que cuenta qué pasó. Se agrupan por familia.
Frontend y backend evolucionan por separado; pueden vivir en servidores distintos.
Sin estado: se replican servidores y balanceadores con facilidad.
Independiente del lenguaje y la plataforma del cliente o del servidor.
El framework moderno para construir APIs con Python
Un framework web moderno y de alto rendimiento para construir APIs con Python 3.10+, basado en las anotaciones de tipos del lenguaje.
Asíncrono de verdad, comparable a Node.js y Go.
Los tipos son la fuente de verdad: validación y docs salen de ahí.
Usado por Microsoft, Uber, Netflix y miles de equipos.
| FastAPI | Flask | Django | |
|---|---|---|---|
| Tipo | Framework de APIs | Micro-framework | Todo incluido |
| Validación | Pydantic, integrada | Manual / extensiones | Forms + DRF |
| Async | Nativo (ASGI) | Parcial | Parcial (ASGI) |
| Docs | Automáticas (OpenAPI) | Extensiones | DRF + drf-spectacular |
| Ideal para | APIs y microservicios | Apps pequeñas | Apps con admin y ORM |
async/awaitFastAPI es una capa delgada y bien pensada sobre dos bibliotecas maduras.
Toolkit ASGI (el sucesor de WSGI) para las partes web: rutas, peticiones, respuestas, WebSockets.
Validación y serialización de datos basada en tipos. El corazón del tipado de FastAPI.
Python 3.10+ y un entorno virtual. La forma moderna es un solo comando.
fastapiuvicorn por separado: viene en fastapi[standard].De cero a una API documentada en cinco minutos
from fastapi import FastAPI
app = FastAPI()
@app.get("/")
async def root():
return {"mensaje": "Hola mundo"}
from fastapi import FastAPI
app = FastAPI()
@app.get("/")
async def root():
return {"mensaje": "Hola mundo"}
app es la instancia con la que se registran rutas.--reload (incluido en dev) el servidor se reinicia al guardar cada cambio.Con solo definir las rutas, FastAPI genera una interfaz interactiva para probar la API.
/productosListar productos/productosCrear producto/productos/{id}Obtener uno/productos/{id}Eliminar/docs usa Swagger UI y /redoc una versión de lectura. Se pueden probar los endpoints sin Postman.Todo FastAPI se apoya en las anotaciones de tipo de Python. No son obligatorias, pero lo cambian todo.
nombre = "Mouse"
precio = 25.9
en_stock = True
nombre: str = "Mouse"
precio: float = 25.9
en_stock: bool = True
list[str], dict[str, int]. Y desde 3.10, str | None en lugar de Optional[str].El corazón de una API: recibir datos y validarlos
Todo lo que veremos en este bloque ocurre en este recorrido.
/@app.get("/")
@app.post("/")
@app.put("/")
@app.patch("/")
@app.delete("/")
@app.options("/")
@app.head("/")
Un segmento variable se declara entre llaves y llega como argumento de la función.
@app.get("/productos/{producto_id}")
async def obtener_producto(producto_id: int):
return {"producto_id": producto_id}
/productos/42, FastAPI llama a la función con producto_id=42.Declarar el tipo activa el parseo y la validación automáticos.
@app.get("/productos/{producto_id}")
async def obtener(producto_id: int):
return {"producto_id": producto_id}
/productos/42 → 200 OK, producto_id es un int./productos/abc → 422 con un error explicando el tipo.Cuando un parámetro solo admite ciertos valores, usa Enum.
from enum import Enum
class Categoria(str, Enum):
electronica = "electronica"
ropa = "ropa"
hogar = "hogar"
@app.get("/productos/categoria/{categoria}")
async def por_categoria(categoria: Categoria):
return {"categoria": categoria}
Van después del ? en la URL, separados por &. Se usan para filtrar, paginar u ordenar.
GET /productos?skip=0&limit=10
@app.get("/productos")
async def listar_productos(skip: int = 0, limit: int = 10):
return productos[skip : skip + limit]
skip: int = 0
limit: int = 10
q: str | None = None
q: str
None.Con Query y Path añades restricciones que se validan y aparecen en /docs.
from fastapi import Path, Query
@app.get("/productos")
async def listar(
skip: int = Query(0, ge=0),
limit: int = Query(10, ge=1, le=100),
q: str | None = Query(None, min_length=2),
):
...
@app.get("/productos/{producto_id}")
async def obtener(producto_id: int = Path(gt=0)):
...
ge/le (rangos) y min_length se validan solos: fuera de rango → 422.Los datos que envía el cliente se modelan con una clase de Pydantic.
from pydantic import BaseModel, Field
class ProductoCreate(BaseModel):
nombre: str
descripcion: str | None = None
precio: float = Field(gt=0)
en_stock: bool = True
@app.post("/productos")
async def crear_producto(producto: ProductoCreate):
return producto
ProductoCreate listo para usar.Si el cuerpo no cumple el esquema, FastAPI responde 422 con el detalle exacto.
{
"nombre": "Mouse", "precio": -5
}
{
"detail": [{
"loc": ["body", "precio"],
"msg": "Input should be greater than 0"
}]
}
if: la regla vive en el modelo y el error indica campo y motivo.FastAPI decide de dónde tomar cada parámetro según su tipo y su nombre.
@app.put("/productos/{producto_id}")
async def actualizar(
producto_id: int,
producto: ProductoCreate,
notificar: bool = False,
):
return {"id": producto_id, **producto.model_dump()}
producto_id → viene del path.producto (Pydantic) → del body.notificar (simple) → de la query.Define qué se devuelve. FastAPI filtra los campos que no estén en el modelo de salida.
class UsuarioIn(BaseModel):
username: str
password: str
class UsuarioOut(BaseModel):
username: str
@app.post("/usuarios", response_model=UsuarioOut)
async def crear_usuario(usuario: UsuarioIn):
return usuario # la contraseña nunca se expone
Por defecto todo responde 200. Indica el código correcto con status_code.
from fastapi import status
@app.post("/productos", status_code=status.HTTP_201_CREATED)
async def crear_producto(producto: ProductoCreate):
return producto
HTTPExceptionPara errores de negocio, lanza una excepción con su código HTTP.
from fastapi import HTTPException
@app.get("/productos/{producto_id}", response_model=ProductoOut)
async def obtener(producto_id: int, db: AsyncSession = Depends(get_db)):
producto = await db.get(Producto, producto_id)
if producto is None:
raise HTTPException(status_code=404, detail="Producto no encontrado")
return producto
{"detail": "..."} con el código indicado. Puedes unificar el formato con @app.exception_handler.async o el tipo en un parámetro: pierdes validación y docs..dict() (Pydantic v1) en vez de .model_dump().async def.response_model y filtrar datos sensibles.Dependencias y tareas en segundo plano
Una dependencia es una función que FastAPI resuelve y entrega a tu ruta automáticamente.
Defines una vez y la usas en muchas rutas.
Fácil de reemplazar por una versión de prueba.
Separa la lógica de las operaciones externas.
from fastapi import Depends
def paginacion(skip: int = 0, limit: int = 10):
return {"skip": skip, "limit": limit}
@app.get("/productos")
async def listar(pag: dict = Depends(paginacion)):
return pag
Depends(paginacion) le dice a FastAPI: resuelve esa función (incluidos sus query params) y pásame el resultado.Para cosas que deben pasar después de responder: correos, notificaciones, logs.
from fastapi import BackgroundTasks
def enviar_correo(email: str, mensaje: str):
...
@app.post("/pedidos")
async def crear_pedido(email: str, background_tasks: BackgroundTasks):
background_tasks.add_task(enviar_correo, email, "Pedido confirmado")
return {"estado": "creado"}
BackgroundTasks también funciona dentro de una dependencia; todas las tareas se fusionan y se ejecutan juntas al final.
def auditar(background_tasks: BackgroundTasks, q: str | None = None):
if q:
background_tasks.add_task(registrar_busqueda, q)
return q
@app.get("/productos")
async def listar(q: str = Depends(auditar)):
return {"q": q}
Mismo proceso, tras la respuesta. Ideal para correos, notificaciones y logs.
Otro proceso, con broker (Redis/RabbitMQ). Para trabajo pesado, reintentos y varios servidores.
BackgroundTasks.Base de datos y autenticación
Nada de secretos en el código: se leen del entorno con pydantic-settings.
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
database_url: str
secret_key: str
model_config = SettingsConfigDict(env_file=".env")
settings = Settings()
pip install pydantic-settings. Añade .env a .gitignore.FastAPI no impone un ORM. El estándar de facto es SQLAlchemy 2.0.
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase
DATABASE_URL = "postgresql+psycopg://tienda:tienda@localhost/tienda"
engine = create_async_engine(DATABASE_URL)
SessionLocal = async_sessionmaker(engine, expire_on_commit=False)
class Base(DeclarativeBase):
pass
pip install "psycopg[binary]". El prefijo postgresql+psycopg usa psycopg 3, que funciona en modo async y sync.lifespanRecursos que se abren al arrancar la app y se cierran al apagarla.
from contextlib import asynccontextmanager
@asynccontextmanager
async def lifespan(app: FastAPI):
await conectar_recursos()
yield
await liberar_recursos()
app = FastAPI(lifespan=lifespan)
startup/shutdown. Ideal para pools de conexión y clientes HTTP.from sqlalchemy.orm import Mapped, mapped_column
from database import Base
class Producto(Base):
__tablename__ = "productos"
id: Mapped[int] = mapped_column(primary_key=True)
nombre: Mapped[str]
precio: Mapped[float]
from pydantic import BaseModel, ConfigDict
class ProductoOut(BaseModel):
id: int
nombre: str
precio: float
model_config = ConfigDict(from_attributes=True)
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
async def get_db():
async with SessionLocal() as db:
yield db
@app.get("/productos", response_model=list[ProductoOut])
async def listar(db: AsyncSession = Depends(get_db)):
result = await db.scalars(select(Producto))
return result.all()
get_db usa yield: abre la sesión, la entrega y la cierra al terminar. Y con async with + await no bloquea el event loop.def (no async def): FastAPI la ejecuta en un threadpool y evita bloquear el loop.El esquema de la base de datos evoluciona con versiones reproducibles.
Base.metadata.create_all() solo en pruebas. En producción, siempre migraciones.Autenticar es saber quién eres; autorizar es saber qué puedes hacer.
from fastapi.security import OAuth2PasswordBearer
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
/token. FastAPI lo usa para pedir el header Authorization: Bearer ... y mostrarlo en /docs.from fastapi import HTTPException
async def get_current_user(token: str = Depends(oauth2_scheme)):
usuario = decodificar_token(token) # verifica firma y expiración
if usuario is None:
raise HTTPException(
status_code=401,
detail="Token inválido",
headers={"WWW-Authenticate": "Bearer"},
)
return usuario
@app.get("/usuarios/me")
async def perfil(usuario: Usuario = Depends(get_current_user)):
return usuario
from fastapi.security import OAuth2PasswordRequestForm
@app.post("/token")
async def login(form: OAuth2PasswordRequestForm = Depends()):
# verificar form.username y form.password
token = crear_token(form.username)
return {"access_token": token, "token_type": "bearer"}
/docs funciona y puedes probar rutas protegidas sin escribir headers a mano. Requiere pip install python-multipart.Ya sabes quién es; ahora define qué puede hacer.
from fastapi import Security
@app.post(
"/productos",
dependencies=[Security(oauth2_scheme, scopes=["productos:escribir"])],
)
async def crear_producto(producto: ProductoCreate):
...
get_current_user. Alternativa simple: un campo rol en el usuario.Si un frontend en otro dominio llama a tu API, el navegador exige CORS.
from fastapi.middleware.cors import CORSMiddleware
app.add_middleware(
CORSMiddleware,
allow_origins=["https://mitienda.com"],
allow_methods=["*"],
allow_headers=["*"],
)
allow_origins=["*"] en producción: enumera los dominios permitidos.Confianza para cambiar el código y ponerlo en producción
Gracias a Starlette, probar una API es como usar requests.
from fastapi.testclient import TestClient
from main import app
client = TestClient(app)
def test_root():
r = client.get("/")
assert r.status_code == 200
assert r.json() == {"mensaje": "Hola mundo"}
test_ y asserts estándar: pytest lo entiende sin configuración.from fastapi import FastAPI
app = FastAPI()
@app.get("/")
async def read_main():
return {"msg": "Hello World"}
from fastapi.testclient import TestClient
from .main import app
client = TestClient(app)
def test_read_main():
r = client.get("/")
assert r.json() == {"msg": "Hello World"}
test_<módulo>.py) mantiene el proyecto navegable a medida que crece.Para probar código asíncrono (por ejemplo una base de datos async) se usa httpx y anyio.
import pytest
from httpx import AsyncClient, ASGITransport
from main import app
@pytest.fixture
def anyio_backend():
return "asyncio"
@pytest.mark.anyio
async def test_root():
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
r = await ac.get("/")
assert r.status_code == 200
app=app directamente al cliente: desde httpx 0.27 se usa ASGITransport.Reemplaza una dependencia real por una de prueba sin tocar el código.
from main import app, get_db
from fastapi.testclient import TestClient
async def db_falsa():
yield FakeSession()
app.dependency_overrides[get_db] = db_falsa
def test_listar():
client = TestClient(app)
assert client.get("/productos").status_code == 200
app.dependency_overrides.APIRouterUn router por recurso: el proyecto crece sin que main.py explote.
from fastapi import APIRouter
router = APIRouter(
prefix="/productos",
tags=["productos"],
)
@router.get("/")
async def listar(...):
...
from routers import productos
app.include_router(productos.router)
prefix evita repetir la ruta y tags agrupa los endpoints en /docs.Imagen reproducible; un contenedor por servicio.
Varios procesos para aprovechar los núcleos de CPU.
Secretos y URLs de BD fuera del código.
@app.get("/productos")def f(id: int)skip: int = 0def f(item: ProductoCreate)Field(gt=0)response_model=Outx = Depends(fn)/docs · /redocConstruye tu primera API hoy: un recurso, una ruta, un test.