API REST para gestión de gastos personales. Proyecto de portfolio con foco en backend: auth con JWT, CRUD, tests, Docker y CI/CD.
- FastAPI + SQLAlchemy 2.0 + Alembic (migraciones versionadas del esquema)
- SQLite en local sin Docker (rápido para desarrollar) / PostgreSQL vía Docker Compose (real)
- JWT (OAuth2 password flow) para auth
- pytest + httpx para tests
- GitHub Actions para CI
python -m venv .venv
source .venv/bin/activate
pip install -r requirements-dev.txt
pre-commit install
alembic upgrade head
uvicorn app.main:app --reloadpre-commit install deja ruff check --fix y ruff format corriendo antes de cada commit, así un fallo de lint se detecta en tu máquina y no en el CI.
Docs interactivas en http://localhost:8000/docs.
El esquema de la base de datos ya no lo crea la app al arrancar — lo crean las migraciones. Flujo habitual:
# después de cambiar un modelo en app/models/
alembic revision --autogenerate -m "descripción del cambio"
# revisar el archivo generado en alembic/versions/ antes de aplicarlo
alembic upgrade head
# deshacer la última migración
alembic downgrade -1
# ver el historial / en qué revisión está la DB actual
alembic history --verbose
alembic currentEn Docker, alembic upgrade head se ejecuta automáticamente antes de arrancar uvicorn (ver Dockerfile).
cp .env.example .env
docker compose up --buildpytest -v
# con cobertura (lo que corre en CI, falla si baja del 90%)
pytest -v --cov=app --cov-report=term-missing --cov-fail-under=90El badge de cobertura es la última cifra medida manualmente — si baja de forma notable al añadir código, actualízalo.
alembic/ # migraciones del esquema (versions/)
app/
├── main.py # entrypoint, routers, exception handlers
├── core/ # config y seguridad (hash, JWT)
├── db/ # base declarativa y sesión
├── models/ # SQLAlchemy models
├── schemas/ # Pydantic schemas
├── api/routes/ # endpoints (auth, categories, expenses)
└── services/ # lógica de negocio separada de los routers
Lo ya scaffoldeado (fases 0-2 del roadmap):
- Estructura del proyecto + Docker Compose + venv
- Modelos
User,Category,Expense - Registro y login con JWT
- CRUD de
CategoriesyExpensesprotegido por auth - Tests de integración de auth y CRUD básico
- CI (lint + test) en GitHub Actions
- Manejo de errores consistente (
{"error": ...}) - Migraciones con Alembic (esquema versionado,
create_alleliminado demain.py) - Tests unitarios de la capa
services/(con mocks, sin DB —tests/unit/) - Reporte de cobertura (
pytest-cov, gate en CI al 90%, badge en README) - Rate limiting en
/auth/login(5 intentos/min por IP,slowapi) - Logging estructurado (JSON por request: método, ruta, status, duración, IP)
- CORS configurado (
CORS_ORIGINSen.env, por defecto habilitalocalhost:5173/3000para el frontend)
Pendiente — requieren cuenta/credenciales externas propias, aplazados deliberadamente:
- Deploy a Railway/Fly.io/Render + endpoint
/healthmonitorizado - (Stretch) Endpoint que categorice un gasto automáticamente llamando a un LLM (necesita API key propia)
Cada uno de estos pendientes debería vivir como un issue individual en GitHub, con su propia rama y PR, para que el historial del repo muestre trabajo incremental.
ExpenseUpdate.datesolo aceptabanull—PATCH /expenses/{id}rechazaba con 422 cualquier intento de cambiar la fecha de un gasto. Causa: para una asignación anotada dentro del cuerpo de una clase (date: Optional[date] = None), Python guarda el valor de la derecha (None) bajo el nombredateen el namespace de la clase antes de evaluar la anotaciónOptional[date]— así que cuando la anotación se evalúa,dateya no apunta al tipodatetime.dateimportado, apunta aNone.Optional[None]colapsa aNoneType, y por eso el schema de OpenAPI generado mostraba"date": {"type": "null"}en vez de una unión real de fecha/null. Solo ocurre porqueExpenseUpdate.datetiene un valor por defecto (= None) —ExpenseCreate.dateyExpenseRead.date, campos obligatorios sin default, nunca lo sufrieron, y esa asimetría es lo que lo hizo fácil de pasar por alto. Detectado desde el cliente tipado del frontend (openapi-typescriptgenerabadate?: null, que no compilaba contra una fecha real) — ningún test existente lo capturó, porqueupdate_expensesolo estaba testeado con mocks que construyenExpenseUpdatedirectamente en Python, sin pasar por la capa HTTP+Pydantic donde vivía el bug real. Arreglado importando el tipo bajo alias (from datetime import date as date_) y añadiendotests/integration/test_expenses.pycon este caso como regresión explícita.