Proyecto práctico: API REST con Flask, SQLAlchemy y JWT (ToDo)
Construiremos una API minimalista para gestionar tareas (ToDo) con registro/login, tokens JWT y acceso por propietario. El objetivo es un proyecto real, fácil de extender y seguro por defecto.
Requisitos previos
- Python 3.8+
- pip
- Conocimientos básicos de HTTP, REST y Python
Estructura del proyecto
todo-api/ ├─ app.py ├─ run.py ├─ config.py ├─ extensions.py ├─ models.py ├─ auth.py ├─ tasks.py ├─ requirements.txt └─ data.db (creado al ejecutar)
requirements.txt
flask
flask-sqlalchemy
flask-jwt-extended
passlib[bcrypt]
1) config.py
import os
class Config:
SECRET_KEY = os.environ.get('SECRET_KEY', 'dev-secret')
SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL', 'sqlite:///data.db')
SQLALCHEMY_TRACK_MODIFICATIONS = False
JWT_SECRET_KEY = os.environ.get('JWT_SECRET_KEY', 'jwt-secret-for-dev')
2) extensions.py
from flask_sqlalchemy import SQLAlchemy
from flask_jwt_extended import JWTManager
db = SQLAlchemy()
jwt = JWTManager()
3) models.py
from extensions import db
from datetime import datetime
from passlib.hash import bcrypt
class User(db.Model):
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(80), unique=True, nullable=False)
password_hash = db.Column(db.String(128), nullable=False)
tasks = db.relationship('Task', backref='owner', lazy=True)
def set_password(self, password):
self.password_hash = bcrypt.hash(password)
def check_password(self, password):
return bcrypt.verify(password, self.password_hash)
class Task(db.Model):
id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String(140), nullable=False)
description = db.Column(db.Text)
done = db.Column(db.Boolean, default=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
4) auth.py (registro y login)
from flask import Blueprint, request, jsonify
from extensions import db
from models import User
from flask_jwt_extended import create_access_token
auth_bp = Blueprint('auth', __name__, url_prefix='/auth')
@auth_bp.route('/register', methods=['POST'])
def register():
data = request.get_json() or {}
username = data.get('username')
password = data.get('password')
if not username or not password:
return jsonify({'msg': 'username and password required'}), 400
if User.query.filter_by(username=username).first():
return jsonify({'msg': 'user exists'}), 400
user = User(username=username)
user.set_password(password)
db.session.add(user)
db.session.commit()
return jsonify({'id': user.id, 'username': user.username}), 201
@auth_bp.route('/login', methods=['POST'])
def login():
data = request.get_json() or {}
username = data.get('username')
password = data.get('password')
user = User.query.filter_by(username=username).first()
if not user or not user.check_password(password):
return jsonify({'msg': 'Bad credentials'}), 401
access = create_access_token(identity=user.id)
return jsonify({'access_token': access})
5) tasks.py (endpoints CRUD)
from flask import Blueprint, request, jsonify
from flask_jwt_extended import jwt_required, get_jwt_identity
from extensions import db
from models import Task
tasks_bp = Blueprint('tasks', __name__, url_prefix='/tasks')
@tasks_bp.route('', methods=['GET'])
@jwt_required()
def list_tasks():
user_id = get_jwt_identity()
tasks = Task.query.filter_by(user_id=user_id).all()
return jsonify([
{'id': t.id, 'title': t.title, 'description': t.description, 'done': t.done, 'created_at': t.created_at.isoformat()}
for t in tasks
])
@tasks_bp.route('', methods=['POST'])
@jwt_required()
def create_task():
user_id = get_jwt_identity()
data = request.get_json() or {}
title = data.get('title')
if not title:
return jsonify({'msg': 'title required'}), 400
task = Task(title=title, description=data.get('description', ''), user_id=user_id)
db.session.add(task)
db.session.commit()
return jsonify({'id': task.id, 'title': task.title}), 201
@tasks_bp.route('/', methods=['GET'])
@jwt_required()
def get_task(task_id):
user_id = get_jwt_identity()
task = Task.query.get_or_404(task_id)
if task.user_id != user_id:
return jsonify({'msg': 'Forbidden'}), 403
return jsonify({'id': task.id, 'title': task.title, 'description': task.description, 'done': task.done, 'created_at': task.created_at.isoformat()})
@tasks_bp.route('/', methods=['PUT'])
@jwt_required()
def update_task(task_id):
user_id = get_jwt_identity()
task = Task.query.get_or_404(task_id)
if task.user_id != user_id:
return jsonify({'msg': 'Forbidden'}), 403
data = request.get_json() or {}
task.title = data.get('title', task.title)
task.description = data.get('description', task.description)
if 'done' in data:
task.done = bool(data.get('done'))
db.session.commit()
return jsonify({'msg': 'updated'})
@tasks_bp.route('/', methods=['DELETE'])
@jwt_required()
def delete_task(task_id):
user_id = get_jwt_identity()
task = Task.query.get_or_404(task_id)
if task.user_id != user_id:
return jsonify({'msg': 'Forbidden'}), 403
db.session.delete(task)
db.session.commit()
return jsonify({'msg': 'deleted'})
6) app.py (factory)
from flask import Flask
from config import Config
from extensions import db, jwt
from auth import auth_bp
from tasks import tasks_bp
def create_app():
app = Flask(__name__)
app.config.from_object(Config)
db.init_app(app)
jwt.init_app(app)
app.register_blueprint(auth_bp)
app.register_blueprint(tasks_bp)
with app.app_context():
# Para desarrollo rápido: crea tablas si no existen
db.create_all()
return app
7) run.py
from app import create_app
app = create_app()
if __name__ == '__main__':
app.run(debug=True)
Instalación y ejecución
python -m venv venv
source venv/bin/activate # o venv\Scripts\activate en Windows
pip install -r requirements.txt
python run.py
Probar la API (ejemplos curl)
# Registrar
curl -X POST http://127.0.0.1:5000/auth/register -H 'Content-Type: application/json' -d '{"username":"alice","password":"secret"}'
# Login -> obtén token
curl -X POST http://127.0.0.1:5000/auth/login -H 'Content-Type: application/json' -d '{"username":"alice","password":"secret"}'
# Imagina que obtienes {"access_token":"..."}
TOKEN=eyJ...
# Crear tarea
curl -X POST http://127.0.0.1:5000/tasks -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" -d '{"title":"Comprar leche","description":"2 litros"}'
# Listar tareas
curl -X GET http://127.0.0.1:5000/tasks -H "Authorization: Bearer $TOKEN"
Por qué estas decisiones (no solo cómo)
- Flask + SQLAlchemy: ligera, fácil de entender y con suficiente potencia para APIs medianas. SQLAlchemy te da portabilidad entre motores y un ORM limpio.
- JWT (flask-jwt-extended): permite una API stateless, adecuada para SPAs o móviles. Aquí usamos el ID de usuario como identidad para verificar recursos.
- passlib + bcrypt: nunca guardes contraseñas en texto. passlib ofrece hashing seguro y actualizable.
- Blueprints y create_app: separación de responsabilidades y testabilidad. Con create_app puedes crear instancias en tests con configuraciones distintas.
- db.create_all() en dev: rápido para arrancar. En producción usa migraciones (Flask-Migrate / Alembic) para cambios estructurales controlados.
Puntos importantes de seguridad y buenas prácticas
- No expongas SECRET_KEY ni JWT_SECRET_KEY en el código. Usa variables de entorno o servicios de secretos.
- Los tokens JWT deben tener expiración configurable; considera usar refresh tokens y revocación (lista negra) si necesitas invalidar sesión.
- Valida y sanea la entrada. En el ejemplo hicimos validaciones mínimas; para producción añade esquemas (pydantic, marshmallow) y límites de tamaño.
- Considera CORS, rate limiting y logging correcto antes de desplegar.
Siguientes pasos prácticos
- Agregar Flask-Migrate para control de esquemas y migraciones.
- Implementar refresh tokens y logout (revocación de tokens).
- Agregar tests unitarios e integración (pytest + pytest-flask).
- Deploy: configurar Gunicorn + Nginx, usar PostgreSQL en producción y habilitar HTTPS.
Consejo avanzado: para APIs con muchos clientes considera un sistema de revocación de JWT (p. ej. blacklist con Redis) y rotación periódica de la clave JWT_SECRET_KEY; además, audita los scopes/claims en el token para permisos más finos.
¿Quieres comentar?
Inicia sesión con Telegram para participar en la conversación