Proyecto práctico: API de gestión de tareas con FastAPI, SQLModel y JWT
Construiremos una API REST para gestionar tareas (TODO) con autenticación por token JWT. El objetivo es un ejemplo real y completo, listo para pruebas locales y extensible a producción.
Requisitos previos
- Python 3.10+
- Conocimientos básicos de Python y HTTP/REST
- pip o un entorno virtual (venv)
Dependencias
Usaremos:
- FastAPI: framework web moderno
- Uvicorn: servidor ASGI para desarrollo
- SQLModel: ORM ligero (integra Pydantic + SQLAlchemy)
- python-jose: generación/verificación de JWT
- passlib[bcrypt]: hash de contraseñas
requirements.txt
fastapi>=0.95
uvicorn[standard]>=0.20
sqlmodel>=0.0.8
python-jose>=3.3
passlib[bcrypt]>=1.7
Estructura de carpetas
todo-api/
├── requirements.txt
└── app/
├── main.py # entrada de la app y routers
├── config.py # configuración (secret, expiración)
├── database.py # engine y sesión
├── models.py # modelos SQLModel (User, Task)
├── schemas.py # esquemas Pydantic si se desea (a veces redundante con SQLModel)
├── auth.py # hashing y utilidades JWT
└── crud.py # operaciones DB
Código completo de los archivos principales
app/config.py
from datetime import timedelta
# En producción mueve esto a variables de entorno
SECRET_KEY = "cambiame_por_una_clave_muy_larga_y_secreta"
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 60 # 1 hora
# Para uso en tests locales
DATABASE_URL = "sqlite:///./todo.db"
app/database.py
from sqlmodel import SQLModel, create_engine, Session
from .config import DATABASE_URL
engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
def init_db():
# Crea tablas si no existen
SQLModel.metadata.create_all(engine)
# dependencia para endpoints
def get_session():
with Session(engine) as session:
yield session
app/models.py
from typing import Optional
from sqlmodel import SQLModel, Field, Relationship
class User(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
username: str = Field(index=True, unique=True)
hashed_password: str
tasks: list["Task"] = Relationship(back_populates="owner")
class TaskBase(SQLModel):
title: str
description: Optional[str] = None
completed: bool = False
class Task(TaskBase, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
owner_id: Optional[int] = Field(default=None, foreign_key="user.id")
owner: Optional[User] = Relationship(back_populates="tasks")
class TaskCreate(TaskBase):
pass
class TaskRead(TaskBase):
id: int
owner_id: int
class UserCreate(SQLModel):
username: str
password: str
class Token(SQLModel):
access_token: str
token_type: str = "bearer"
app/auth.py
from datetime import datetime, timedelta
from typing import Optional
from jose import jwt, JWTError
from passlib.context import CryptContext
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from sqlmodel import Session, select
from .config import SECRET_KEY, ALGORITHM, ACCESS_TOKEN_EXPIRE_MINUTES
from .models import User
from .database import get_session
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/token")
def verify_password(plain_password, hashed_password):
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password):
return pwd_context.hash(password)
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
to_encode = data.copy()
expire = datetime.utcnow() + (expires_delta or timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES))
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
return encoded_jwt
def get_current_user(token: str = Depends(oauth2_scheme), session: Session = Depends(get_session)) -> User:
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="No se pudo validar las credenciales",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
username: str = payload.get("sub")
if username is None:
raise credentials_exception
except JWTError:
raise credentials_exception
user = session.exec(select(User).where(User.username == username)).first()
if user is None:
raise credentials_exception
return user
app/crud.py
from sqlmodel import Session, select
from .models import User, Task, TaskCreate
from .auth import get_password_hash, verify_password
# Usuarios
def get_user_by_username(session: Session, username: str):
return session.exec(select(User).where(User.username == username)).first()
def create_user(session: Session, username: str, password: str) -> User:
hashed = get_password_hash(password)
user = User(username=username, hashed_password=hashed)
session.add(user)
session.commit()
session.refresh(user)
return user
# Autenticación
def authenticate_user(session: Session, username: str, password: str):
user = get_user_by_username(session, username)
if not user:
return None
if not verify_password(password, user.hashed_password):
return None
return user
# Tasks
def create_task(session: Session, owner_id: int, task_in: TaskCreate) -> Task:
task = Task.from_orm(task_in) # TaskCreate -> TaskBase data
task.owner_id = owner_id
session.add(task)
session.commit()
session.refresh(task)
return task
def get_tasks_for_user(session: Session, owner_id: int):
return session.exec(select(Task).where(Task.owner_id == owner_id)).all()
def get_task_by_id(session: Session, task_id: int):
return session.get(Task, task_id)
def update_task(session: Session, task: Task, **fields):
for key, value in fields.items():
setattr(task, key, value)
session.add(task)
session.commit()
session.refresh(task)
return task
def delete_task(session: Session, task: Task):
session.delete(task)
session.commit()
app/main.py
from fastapi import FastAPI, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from sqlmodel import Session
from datetime import timedelta
from .database import init_db, get_session
from .models import UserCreate, Token, TaskCreate, TaskRead
from .crud import create_user, authenticate_user, create_task, get_tasks_for_user, get_task_by_id, update_task, delete_task
from .auth import create_access_token, get_current_user
from .config import ACCESS_TOKEN_EXPIRE_MINUTES
app = FastAPI(title="Todo API con FastAPI y JWT")
@app.on_event("startup")
def on_startup():
init_db()
# Registro
@app.post("/register", response_model=Token)
def register(user_in: UserCreate, session: Session = Depends(get_session)):
existing = session.exec(
session.query(User).filter(User.username == user_in.username)
).first()
# Pero como usamos SQLModel, mejor usar CRUD:
from .crud import get_user_by_username
if get_user_by_username(session, user_in.username):
raise HTTPException(status_code=400, detail="Usuario ya existe")
user = create_user(session, user_in.username, user_in.password)
access_token = create_access_token(data={"sub": user.username})
return {"access_token": access_token, "token_type": "bearer"}
# Token (login)
@app.post("/token", response_model=Token)
def login(form_data: OAuth2PasswordRequestForm = Depends(), session: Session = Depends(get_session)):
user = authenticate_user(session, form_data.username, form_data.password)
if not user:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Usuario o contraseña incorrectos")
access_token = create_access_token(data={"sub": user.username}, expires_delta=timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES))
return {"access_token": access_token, "token_type": "bearer"}
# CRUD de tasks (protegidos)
@app.post("/tasks", response_model=TaskRead)
def create_task_endpoint(task_in: TaskCreate, current_user: User = Depends(get_current_user), session: Session = Depends(get_session)):
task = create_task(session, current_user.id, task_in)
return task
@app.get("/tasks", response_model=list[TaskRead])
def list_tasks(current_user: User = Depends(get_current_user), session: Session = Depends(get_session)):
return get_tasks_for_user(session, current_user.id)
@app.get("/tasks/{task_id}", response_model=TaskRead)
def get_task(task_id: int, current_user: User = Depends(get_current_user), session: Session = Depends(get_session)):
task = get_task_by_id(session, task_id)
if not task or task.owner_id != current_user.id:
raise HTTPException(status_code=404, detail="Tarea no encontrada")
return task
@app.put("/tasks/{task_id}", response_model=TaskRead)
def update_task_endpoint(task_id: int, task_in: TaskCreate, current_user: User = Depends(get_current_user), session: Session = Depends(get_session)):
task = get_task_by_id(session, task_id)
if not task or task.owner_id != current_user.id:
raise HTTPException(status_code=404, detail="Tarea no encontrada")
updated = update_task(session, task, title=task_in.title, description=task_in.description, completed=task_in.completed)
return updated
@app.delete("/tasks/{task_id}", status_code=204)
def delete_task_endpoint(task_id: int, current_user: User = Depends(get_current_user), session: Session = Depends(get_session)):
task = get_task_by_id(session, task_id)
if not task or task.owner_id != current_user.id:
raise HTTPException(status_code=404, detail="Tarea no encontrada")
delete_task(session, task)
return None
Por qué esta arquitectura y decisiones
- FastAPI: rendimiento, validación automática con Pydantic y documentación interactiva (OpenAPI).
- SQLModel: combina modelos de datos (Pydantic) y ORM (SQLAlchemy) en una única capa, reduciendo duplicación.
- SQLite: ideal para desarrollo local. Cambia a PostgreSQL en producción y configura correctamente el engine.
- JWT (python-jose): tokens stateless, simples de usar para APIs. Controla caducidad y revocación si necesitas logout efectivo (ver notas).
- passlib + bcrypt: hashing seguro de contraseñas.
- Separación en módulos (auth, crud, models) facilita pruebas y mantenimiento.
Cómo ejecutar (local)
- Crear entorno virtual e instalar deps:
python -m venv .venv source .venv/bin/activate # o .venv\Scripts\activate en Windows pip install -r requirements.txt - Iniciar la app:
uvicorn app.main:app --reload - La documentación interactiva estará en http://127.0.0.1:8000/docs
Pruebas rápidas (curl)
# Registrar
curl -X POST "http://127.0.0.1:8000/register" -H "Content-Type: application/json" -d '{"username":"alice","password":"secret"}'
# Login
curl -X POST "http://127.0.0.1:8000/token" -F "username=alice" -F "password=secret"
# Crear tarea (usa el token devuelto como Bearer)
curl -X POST "http://127.0.0.1:8000/tasks" -H "Authorization: Bearer " -H "Content-Type: application/json" -d '{"title":"Comprar leche","description":"2 litros","completed":false}'
# Listar tareas
curl -X GET "http://127.0.0.1:8000/tasks" -H "Authorization: Bearer "
Buenas prácticas y extensiones posibles
- Migraciones: añade Alembic si vas a usar PostgreSQL en producción.
- Rotación y revocación de tokens: implementa refresh tokens y una blacklist si necesites logout inmediato.
- Rate limiting y protección contra fuerza bruta en endpoints de auth.
- Validación adicional y tareas por prioridad/etiquetas si la app crece.
Consejo avanzado: nunca guardes la clave SECRET_KEY en el repositorio. En producción usa un gestor de secretos o variables de entorno, protege los endpoints de autenticación con límites de intentos y considera implementar tokens de refresco y revocación (lista negra) para poder invalidar sesiones en caso de compromiso.
¿Quieres comentar?
Inicia sesión con Telegram para participar en la conversación