Module 6: Project — CRUD API (To-Do List)
Paso 1: Setup, Modelos y Datos
Descripción
Antes de escribir un solo endpoint, necesitas los cimientos. En esta cápsula creas la estructura del proyecto, defines todos los modelos Pydantic que la API va a usar, configuras los datos iniciales, y dejas la aplicación FastAPI lista para recibir rutas.
Al terminar esta cápsula tendrás:
- Carpeta
todo-api/con estructura profesional - Modelos Pydantic:
TaskCreate,TaskUpdate,TaskPatch,TaskResponse - Enums:
StatusyPriority - Datos iniciales (5 tareas precargadas)
- App FastAPI configurada pero sin endpoints de tareas todavía
Crear la estructura del proyecto
Abre tu terminal y crea la estructura:
mkdir todo-api
cd todo-api
python -m venv venv
source venv/bin/activate # macOS/Linux
# venv\Scripts\activate # Windows
pip install fastapi uvicorn
pip freeze > requirements.txt
Ahora crea la estructura de archivos:
mkdir app
touch app/__init__.py app/main.py app/models.py app/routes.py app/data.py
touch README.md
Tu proyecto debe verse así:
todo-api/
├── app/
│ ├── __init__.py
│ ├── main.py
│ ├── models.py
│ ├── routes.py
│ └── data.py
├── venv/
├── requirements.txt
└── README.md
¿Por qué __init__.py?
El archivo __init__.py (vacío) le dice a Python que app/ es un paquete. Sin él, los imports entre archivos (from app.models import TaskCreate) no funcionarían. Déjalo vacío — no necesita contenido.
¿Por qué requirements.txt?
Cualquier persona que clone tu proyecto puede instalar las dependencias con un solo comando: pip install -r requirements.txt. Es la convención estándar en proyectos Python.
Verificar la estructura
Después de crear todos los archivos, verifica que la estructura es correcta:
# Desde todo-api/
find app -type f | sort
Deberías ver:
app/__init__.py
app/data.py
app/main.py
app/models.py
app/routes.py
Si falta algún archivo, créalo antes de continuar. Cada archivo depende de los otros para los imports.
Paso 1: Los modelos Pydantic — app/models.py
Este archivo define todos los modelos de datos de tu API. Cada modelo tiene un propósito específico:
| Modelo | Propósito | ¿Cuándo se usa? |
|---|---|---|
Status | Enum de estados | En modelos y filtros |
Priority | Enum de prioridades | En modelos y filtros |
TaskCreate | Datos para crear tarea | Body de POST /tasks |
TaskUpdate | Datos para reemplazo completo | Body de PUT /tasks/{id} |
TaskPatch | Datos para actualización parcial | Body de PATCH /tasks/{id} |
TaskResponse | Datos que retorna la API | Response de todos los endpoints |
TaskListResponse | Lista paginada de tareas | Response de GET /tasks |
El código completo
# app/models.py
from datetime import datetime
from enum import Enum
from pydantic import BaseModel, Field, field_validator
class Status(str, Enum):
"""Estados posibles de una tarea."""
pending = "pending"
in_progress = "in_progress"
completed = "completed"
class Priority(str, Enum):
"""Niveles de prioridad."""
low = "low"
medium = "medium"
high = "high"
class TaskCreate(BaseModel):
"""Modelo para crear una tarea nueva.
Solo incluye los campos que el usuario proporciona.
id, created_at y updated_at se generan automáticamente.
"""
title: str = Field(
min_length=1,
max_length=100,
description="Título de la tarea",
examples=["Comprar víveres"],
)
description: str = Field(
default="",
max_length=500,
description="Descripción detallada de la tarea",
examples=["Ir al supermercado y comprar frutas, verduras y pan"],
)
priority: Priority = Field(
default=Priority.medium,
description="Nivel de prioridad",
)
status: Status = Field(
default=Status.pending,
description="Estado inicial de la tarea",
)
@field_validator("title")
@classmethod
def title_must_not_be_whitespace(cls, v: str) -> str:
if not v.strip():
raise ValueError("Title cannot be empty or whitespace only")
return v.strip()
@field_validator("description")
@classmethod
def clean_description(cls, v: str) -> str:
return v.strip()
class TaskUpdate(BaseModel):
"""Modelo para reemplazo completo (PUT).
Todos los campos son requeridos porque PUT reemplaza
el recurso completo.
"""
title: str = Field(
min_length=1,
max_length=100,
description="Título de la tarea",
)
description: str = Field(
default="",
max_length=500,
description="Descripción detallada",
)
priority: Priority = Field(
description="Nivel de prioridad",
)
status: Status = Field(
description="Estado de la tarea",
)
@field_validator("title")
@classmethod
def title_must_not_be_whitespace(cls, v: str) -> str:
if not v.strip():
raise ValueError("Title cannot be empty or whitespace only")
return v.strip()
@field_validator("description")
@classmethod
def clean_description(cls, v: str) -> str:
return v.strip()
class TaskPatch(BaseModel):
"""Modelo para actualización parcial (PATCH).
Todos los campos son opcionales. Solo los campos incluidos
en el request se actualizan; el resto permanece intacto.
model_dump(exclude_unset=True) es clave para que funcione.
"""
title: str | None = Field(
default=None,
min_length=1,
max_length=100,
description="Nuevo título",
)
description: str | None = Field(
default=None,
max_length=500,
description="Nueva descripción",
)
priority: Priority | None = Field(
default=None,
description="Nueva prioridad",
)
status: Status | None = Field(
default=None,
description="Nuevo estado",
)
@field_validator("title")
@classmethod
def title_must_not_be_whitespace(cls, v: str | None) -> str | None:
if v is not None and not v.strip():
raise ValueError("Title cannot be empty or whitespace only")
return v.strip() if v else v
class TaskResponse(BaseModel):
"""Modelo de respuesta para una tarea.
Incluye todos los campos, incluyendo los auto-generados:
id, created_at, updated_at.
"""
id: int
title: str
description: str
status: Status
priority: Priority
created_at: datetime
updated_at: datetime
class TaskListResponse(BaseModel):
"""Respuesta paginada de lista de tareas."""
status: str = "success"
total: int = Field(description="Total de tareas que coinciden con los filtros")
count: int = Field(description="Cantidad de tareas en esta página")
skip: int = Field(description="Resultados saltados")
limit: int = Field(description="Límite de resultados por página")
data: list[TaskResponse]
Decisiones de diseño de los modelos
¿Por qué str, Enum y no solo Enum?
class Status(str, Enum):
pending = "pending"
Al heredar de str y Enum, los valores del enum son strings. Esto permite que FastAPI los serialice directamente como "pending" en JSON, en vez de Status.pending. También permite que funcionen como query params: GET /tasks?status=pending.
Sin str, tendrías que convertir manualmente entre el enum y su valor string en cada endpoint.
¿Por qué modelos separados por operación?
Podrías usar un solo modelo Task para todo. Pero eso crea problemas:
# ❌ Un solo modelo — el id es requerido al crear?
class Task(BaseModel):
id: int # ¿De dónde sale al crear? Error
title: str
created_at: datetime # ¿El cliente lo envía? No
Con modelos separados:
- TaskCreate — solo campos que el usuario proporciona (no
id, no timestamps) - TaskUpdate — campos requeridos para reemplazo completo
- TaskPatch — campos opcionales para actualización parcial
- TaskResponse — todos los campos, incluyendo los auto-generados
Cada modelo hace exactamente una cosa. El schema en /docs refleja exactamente lo que el endpoint espera y retorna.
¿Por qué description tiene default ""?
Porque la descripción es opcional al crear una tarea. Si el usuario no la envía, la tarea se crea con descripción vacía. Es más limpio que None porque evitas chequeos de if description is not None en toda la app.
¿Por qué TaskPatch usa | None en vez de Optional?
str | None es la sintaxis moderna de Python 3.10+. Es equivalente a Optional[str] pero más legible. Si usas Python 3.9, cámbialo a Optional[str] con el import correspondiente:
from typing import Optional
title: Optional[str] = Field(default=None, ...)
¿Por qué @field_validator para title?
Field(min_length=1) ya previene strings vacíos, pero no previene strings que son solo espacios: " " pasa la validación de longitud pero no es un título válido. El validator hace strip() y verifica que quede contenido real.
TaskListResponse — ¿Por qué no solo list[TaskResponse]?
Podrías retornar directamente la lista, pero TaskListResponse agrega metadata de paginación:
{
"status": "success",
"total": 45,
"count": 10,
"skip": 0,
"limit": 10,
"data": [...]
}
El cliente sabe cuántas tareas hay en total (total), cuántas recibió en esta página (count), y puede calcular si hay más páginas. Sin esta metadata, el cliente no sabe si hay más resultados después de los que recibió.
Paso 2: Almacenamiento y helpers — app/data.py
Este archivo maneja el almacenamiento en memoria y provee funciones auxiliares que los endpoints usan.
# app/data.py
from datetime import datetime, timezone
tasks: list[dict] = [
{
"id": 1,
"title": "Aprender FastAPI",
"description": "Completar la guía de FastAPI Fundamentals",
"status": "completed",
"priority": "high",
"created_at": datetime(2026, 1, 15, 9, 0, 0, tzinfo=timezone.utc),
"updated_at": datetime(2026, 3, 10, 14, 30, 0, tzinfo=timezone.utc),
},
{
"id": 2,
"title": "Construir To-Do API",
"description": "Proyecto final del módulo 6",
"status": "in_progress",
"priority": "high",
"created_at": datetime(2026, 3, 10, 10, 0, 0, tzinfo=timezone.utc),
"updated_at": datetime(2026, 3, 10, 10, 0, 0, tzinfo=timezone.utc),
},
{
"id": 3,
"title": "Comprar víveres",
"description": "Frutas, verduras, pan y leche",
"status": "pending",
"priority": "medium",
"created_at": datetime(2026, 3, 11, 8, 0, 0, tzinfo=timezone.utc),
"updated_at": datetime(2026, 3, 11, 8, 0, 0, tzinfo=timezone.utc),
},
{
"id": 4,
"title": "Revisar correos",
"description": "",
"status": "pending",
"priority": "low",
"created_at": datetime(2026, 3, 12, 7, 0, 0, tzinfo=timezone.utc),
"updated_at": datetime(2026, 3, 12, 7, 0, 0, tzinfo=timezone.utc),
},
{
"id": 5,
"title": "Preparar presentación",
"description": "Slides para la reunión del lunes sobre el proyecto API",
"status": "pending",
"priority": "high",
"created_at": datetime(2026, 3, 12, 9, 30, 0, tzinfo=timezone.utc),
"updated_at": datetime(2026, 3, 12, 9, 30, 0, tzinfo=timezone.utc),
},
]
id_counter: int = 6
def generate_id() -> int:
"""Genera el siguiente ID disponible e incrementa el contador."""
global id_counter
current = id_counter
id_counter += 1
return current
def get_current_timestamp() -> datetime:
"""Retorna la fecha/hora actual en UTC."""
return datetime.now(timezone.utc)
def find_task(task_id: int) -> dict | None:
"""Busca una tarea por ID. Retorna None si no existe."""
return next((t for t in tasks if t["id"] == task_id), None)
Decisiones de diseño del almacenamiento
¿Por qué una lista de dicts?
Es la forma más simple de almacenar datos en memoria. No necesitas instalar nada, no hay conexiones, no hay esquemas de base de datos. Cuando cambies a una base de datos real (en la guía de SQLAlchemy + PostgreSQL del path), solo cambiarás este archivo.
¿Por qué un id_counter global?
Es la forma más simple de garantizar IDs únicos incrementales. En una base de datos real, el motor de base de datos maneja esto automáticamente con auto-increment. Aquí lo simulas con un contador.
La alternativa sería max(t["id"] for t in tasks) + 1, pero eso falla si borras la tarea con el ID más alto y luego creas una nueva — podrías generar un ID que ya existió.
¿Por qué datetime.now(timezone.utc) y no datetime.now()?
datetime.now() retorna la hora local sin información de zona horaria (naive datetime). datetime.now(timezone.utc) retorna la hora UTC con timezone info (aware datetime). Las APIs deben usar siempre UTC porque los clientes pueden estar en cualquier zona horaria. El cliente convierte de UTC a su zona local.
¿Por qué find_task retorna None en vez de lanzar error?
Porque la decisión de qué hacer cuando una tarea no existe pertenece al endpoint, no al helper de datos. Algunos endpoints quieren retornar 404, otros podrían querer un comportamiento diferente. find_task solo busca — el endpoint decide qué hacer con el resultado.
¿Por qué 5 tareas precargadas?
Porque un API vacío es difícil de probar. Con datos iniciales puedes probar GET /tasks, filtros y paginación inmediatamente sin tener que crear tareas primero. Las 5 tareas cubren diferentes combinaciones de status y priority para que los filtros tengan resultados interesantes.
Paso 3: La aplicación FastAPI — app/main.py
Este archivo configura la aplicación FastAPI. En esta cápsula solo ponemos la configuración base. Los endpoints vienen en la siguiente cápsula, y el error handling profesional + CORS vienen en la cápsula 04.
# app/main.py
from fastapi import FastAPI
from app.routes import router
app = FastAPI(
title="To-Do List API",
description=(
"API REST para gestión de tareas. "
"CRUD completo con filtros, paginación, validación Pydantic y error handling. "
"Proyecto final — FastAPI Fundamentals Guide."
),
version="1.0.0",
)
app.include_router(router)
¿Qué es include_router?
En los módulos anteriores, todos los endpoints se definían directamente en main.py con @app.get(...), @app.post(...), etc. Pero cuando tienes muchos endpoints, main.py se vuelve gigante e inmanejable.
APIRouter permite definir endpoints en un archivo separado (routes.py) y luego "montarlos" en la app con include_router. El router funciona igual que la app — usas @router.get(...) en vez de @app.get(...) — pero vive en su propio archivo.
Piénsalo así: main.py es el manager que configura la app. routes.py es el equipo que ejecuta el trabajo.
Paso 4: El router placeholder — app/routes.py
Por ahora, routes.py solo tiene la estructura base. Los endpoints se implementan en la siguiente cápsula.
# app/routes.py
from fastapi import APIRouter
router = APIRouter()
@router.get("/health", tags=["General"])
def health_check():
"""Verificar que la API está funcionando."""
return {
"status": "healthy",
"service": "To-Do List API",
"version": "1.0.0",
}
¿Por qué un health check?
Es una práctica estándar en APIs profesionales. Un health check responde a la pregunta "¿el servicio está corriendo?" sin hacer nada más. En producción, los load balancers y herramientas de monitoreo consultan este endpoint para saber si el servicio está disponible. Aquí lo incluyes para demostrar la práctica.
Cómo se conectan los archivos
Para visualizar cómo los archivos se importan entre sí:
main.py
├── import FastAPI
├── from app.routes import router ← routes.py
└── app.include_router(router)
routes.py
├── import APIRouter
├── from app.models import ... ← models.py (en la cápsula 03)
├── from app.data import ... ← data.py (en la cápsula 03)
└── define endpoints con @router.get/post/etc.
models.py
├── import BaseModel, Field, Enum
└── define TaskCreate, TaskUpdate, TaskPatch, TaskResponse
data.py
├── import datetime
└── define tasks[], generate_id(), find_task(), get_current_timestamp()
El flujo de un request:
Cliente → uvicorn → main.py (app) → routes.py (router) → data.py (storage)
↑
models.py (validación)
- El cliente envía un request a
http://127.0.0.1:8000/tasks - Uvicorn lo recibe y lo pasa a la app FastAPI en
main.py - La app lo enruta al router definido en
routes.py - El endpoint usa modelos de
models.pypara validar los datos - El endpoint usa funciones de
data.pypara buscar/guardar datos - La respuesta regresa por el mismo camino
¿Por qué no poner todo en un archivo?
En los módulos 1-5 todo estaba en main.py porque el foco era aprender un concepto a la vez. Pero en un proyecto real, un solo archivo con modelos, datos, configuración y endpoints se vuelve difícil de navegar, difícil de mantener, y difícil de trabajar en equipo (dos personas editando el mismo archivo = conflictos de merge).
La separación que usas aquí es el primer paso hacia la arquitectura de un proyecto profesional. En proyectos más grandes, routes.py se divide en múltiples archivos (routes/tasks.py, routes/users.py), y models.py se divide igual. Pero para un proyecto de este tamaño, la estructura de 4 archivos es el balance perfecto entre organización y simplicidad.
Verificar que todo funciona
En este punto tu proyecto tiene la estructura completa con modelos definidos pero solo un endpoint (/health). Vamos a verificar que todo levanta correctamente.
1. Levantar el servidor
cd todo-api
source venv/bin/activate
uvicorn app.main:app --reload
Deberías ver:
INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
INFO: Started reloader process [xxxxx] using WatchFiles
INFO: Started server process [xxxxx]
INFO: Waiting for application startup.
INFO: Application startup complete.
Si ves un error de import, verifica:
- Que
app/__init__.pyexiste (puede estar vacío) - Que estás ejecutando
uvicorndesde la carpetatodo-api/, no desde dentro deapp/ - Que el virtual environment está activado
2. Verificar el health check
curl -s http://127.0.0.1:8000/health | python -m json.tool
{
"status": "healthy",
"service": "To-Do List API",
"version": "1.0.0"
}
3. Verificar /docs
Abre http://127.0.0.1:8000/docs en tu navegador. Deberías ver:
- Título: "To-Do List API"
- Descripción: La descripción que configuraste
- Versión: 1.0.0
- Un endpoint:
GET /healthen la sección "General"
4. Verificar los schemas
En /docs, baja a la sección "Schemas" al final de la página. Deberías ver todos los modelos Pydantic que definiste:
TaskCreate— con campos title, description, priority, statusTaskUpdate— con campos title, description, priority, status (todos requeridos)TaskPatch— con campos opcionalesTaskResponse— con id, timestampsTaskListResponse— con metadata de paginaciónStatus— enum con pending, in_progress, completedPriority— enum con low, medium, high
Aunque todavía no hay endpoints que usen estos modelos, FastAPI los muestra porque están importados en la app.
Troubleshooting
Problema 1: ModuleNotFoundError: No module named 'app'
Causa: Estás ejecutando uvicorn desde el directorio incorrecto.
# ❌ Desde dentro de app/
cd app
uvicorn main:app --reload # Error: no puede encontrar app.routes
# ✅ Desde todo-api/
cd todo-api
uvicorn app.main:app --reload # Correcto
Problema 2: ImportError: cannot import name 'router' from 'app.routes'
Causa: app/routes.py está vacío o no define router.
Verifica que routes.py tiene:
from fastapi import APIRouter
router = APIRouter()
Problema 3: TypeError: unsupported operand type(s) for |: 'type' and 'NoneType'
Causa: Estás usando Python 3.9 o anterior. La sintaxis str | None requiere Python 3.10+.
Solución para Python 3.9:
from __future__ import annotations
Agrega esta línea como primera línea del archivo (antes de cualquier otro import). Habilita la evaluación "lazy" de type hints que permite la sintaxis X | Y en Python 3.9.
Problema 4: Los schemas no aparecen en /docs
Causa: Los modelos están definidos pero ningún endpoint los usa como response_model o parámetro de body. FastAPI solo muestra schemas que están referenciados por al menos un endpoint.
Solución: Esto se resuelve en la siguiente cápsula cuando implementes los endpoints. Por ahora, es esperado que solo veas el schema del health check.
Problema 5: AttributeError: 'str' object has no attribute 'value'
Causa: Estás usando .value en un string cuando debería ser un Enum, o viceversa.
Los datos almacenados en data.py son strings ("pending", "high"). Los enums que llegan de los query params son objetos Enum (Status.pending). Cuando compares, usa .value para extraer el string del enum:
# ✅ Correcto: enum.value produce el string
task_status.value == "pending"
# ❌ Incorrecto: comparar enum con string directamente
task_status == "pending" # Esto funciona con str Enum, pero puede confundir
Problema 6: Pydantic warnings sobre .dict() deprecado
Causa: Estás usando .dict() (Pydantic v1) en vez de .model_dump() (Pydantic v2).
# ❌ Pydantic v1 — funciona pero genera warning
task.dict()
# ✅ Pydantic v2 — la forma actual
task.model_dump()
Relación entre modelos y endpoints
Aunque los endpoints se implementan en la cápsula 03, es útil entender de antemano cómo se conectan los modelos con los endpoints:
POST /tasks → Recibe TaskCreate → Retorna TaskResponse
GET /tasks → Sin body → Retorna TaskListResponse
GET /tasks/{id} → Sin body → Retorna TaskResponse
PUT /tasks/{id} → Recibe TaskUpdate → Retorna TaskResponse
PATCH /tasks/{id} → Recibe TaskPatch → Retorna TaskResponse
DELETE /tasks/{id} → Sin body → Sin response (204)
Cada modelo "protege" un endpoint:
TaskCreateasegura que el POST recibe un título válido y campos correctosTaskUpdateasegura que el PUT recibe todos los campos necesariosTaskPatchpermite enviar cualquier subconjunto de camposTaskResponsegarantiza que la respuesta siempre tiene la misma estructura
Si un cliente envía datos que no cumplen con el modelo, Pydantic rechaza la request con un error 422 antes de que tu código se ejecute. No necesitas validar manualmente.
Estado del proyecto después de esta cápsula
✅ Completado:
├── Estructura de carpetas profesional (app/, __init__.py, etc.)
├── requirements.txt con dependencias
├── Modelos Pydantic definidos (TaskCreate, TaskUpdate, TaskPatch, TaskResponse)
├── Enums definidos (Status, Priority)
├── Datos iniciales (5 tareas precargadas)
├── Helpers de datos (generate_id, get_current_timestamp, find_task)
├── App FastAPI con metadata de docs
├── Router con health check
└── Servidor corriendo en localhost:8000
⬜ Pendiente (cápsulas siguientes):
├── Endpoints CRUD (cápsula 03)
├── Error handling profesional (cápsula 04)
├── CORS (cápsula 04)
├── Stats endpoint (cápsula 04)
├── Docs personalizado (cápsula 04)
└── Verificación y entrega (cápsula 05)
Revisión de los archivos
Antes de continuar, asegúrate de que tus 4 archivos tienen el contenido correcto. Aquí está el resumen de cada archivo en este punto:
app/models.py — 2 enums, 5 modelos
Status (str, Enum): pending, in_progress, completed
Priority (str, Enum): low, medium, high
TaskCreate: title (req), description (opt), priority (opt), status (opt)
TaskUpdate: title (req), description (opt), priority (req), status (req)
TaskPatch: title (opt), description (opt), priority (opt), status (opt)
TaskResponse: id, title, description, status, priority, created_at, updated_at
TaskListResponse: status, total, count, skip, limit, data
app/data.py — 5 tareas, 3 helpers
tasks: lista con 5 tareas precargadas
id_counter: empieza en 6
generate_id(): retorna id_counter e incrementa
get_current_timestamp(): retorna datetime.now(UTC)
find_task(task_id): busca en tasks, retorna dict o None
app/main.py — configuración
FastAPI con título, descripción, versión
include_router(router) para montar routes.py
app/routes.py — 1 endpoint
APIRouter()
GET /health → {"status": "healthy", ...}
Si todo está correcto y el servidor corre sin errores, los cimientos están listos.
Resumen
- Creaste la estructura profesional
todo-api/app/con separación de responsabilidades models.pydefine enums y 5 modelos Pydantic (TaskCreate,TaskUpdate,TaskPatch,TaskResponse,TaskListResponse)data.pytiene tareas precargadas con diferentes estados y prioridades, y helpers reutilizablesmain.pyconfigura la app FastAPI con metadata para/docsy monta el router- Los modelos usan Pydantic v2:
Field(),@field_validator,model_dump() - Modelos separados por operación evitan problemas de campos requeridos/opcionales
Recursos adicionales
- Pydantic v2 - BaseModel — Referencia completa de modelos
- Pydantic v2 - Field — Opciones de Field() y constraints
- Pydantic v2 - Validators — @field_validator y @model_validator
- Python - Enum — Documentación oficial de enumeraciones
- Python - datetime — datetime con timezone
- FastAPI - Bigger Applications — APIRouter y organización de proyectos