Module 6: Project — CRUD API (To-Do List)
Proyecto Final: To-Do List API — Implementación de Referencia
Descripción
Llegaste. Este es el último documento de toda la guía FastAPI Fundamentals. Si estás leyendo esto, significa que construiste tu To-Do List API paso a paso: definiste modelos, implementaste endpoints CRUD, agregaste filtros y paginación, configuraste error handling profesional, CORS, stats y documentación.
Esta cápsula tiene dos propósitos:
- Implementación de referencia completa — Todos los archivos del proyecto en su versión final, para que compares tu código y verifiques que no falta nada.
- Cierre de la guía — Reflexión sobre tu viaje desde el Módulo 1 hasta aquí, herramientas que dominas, y el camino que sigue.
No hay código nuevo. No hay conceptos nuevos. Lo que hay es la versión definitiva de tu proyecto y el reconocimiento de lo que lograste.
1. El momento de la verdad
Detente un momento y mira tu proyecto.
Hace unas semanas no sabías qué era FastAPI. No sabías cómo crear un endpoint, qué era Pydantic, ni por qué un 404 es diferente de un 200. Ahora tienes una API REST completa con CRUD, validación, filtros, paginación, error handling profesional, CORS y documentación automática.
Eso no es trivial. Eso es una habilidad real.
Lo que sigue es la referencia completa para que verifiques tu trabajo, una rúbrica para que te evalúes, y una mirada al camino recorrido. Pero antes: construiste una API profesional. No copiaste y pegaste — la construiste pieza por pieza, entendiendo cada decisión. Eso te pone por delante del 90% de personas que solo leen tutoriales.
2. Implementación de referencia completa
Compara tu código con esta referencia. Diferencias menores de estilo están bien. Lo importante es que la funcionalidad sea equivalente.
Estructura del proyecto
todo-api/
├── app/
│ ├── __init__.py ← Vacío (marca el paquete)
│ ├── main.py ← App, CORS, middleware, exception handlers
│ ├── models.py ← Modelos Pydantic (5+ modelos)
│ ├── routes.py ← Todos los endpoints
│ ├── data.py ← Datos iniciales, helpers
│ ├── exceptions.py ← Custom exceptions
│ └── utils.py ← Helpers de respuesta
├── venv/
├── requirements.txt
└── README.md
app/models.py
La decisión de usar Literal en vez de Enum es intencional: los datos se almacenan como strings en la lista de dicts, y Literal valida directamente sin necesidad de .value.
from pydantic import BaseModel, Field, field_validator
from typing import Literal
TaskStatus = Literal["pending", "in_progress", "completed"]
TaskPriority = Literal["low", "medium", "high", "urgent"]
class TaskBase(BaseModel):
title: str = Field(
min_length=1, max_length=200, description="Task title",
)
description: str | None = Field(
default=None, max_length=1000, description="Task description",
)
status: TaskStatus = Field(
default="pending", description="Task status",
)
priority: TaskPriority = Field(
default="medium", description="Task priority",
)
@field_validator("title")
@classmethod
def title_not_empty(cls, v: str) -> str:
if not v.strip():
raise ValueError("Title cannot be empty or whitespace")
return v.strip()
class TaskCreate(TaskBase):
pass
class TaskUpdate(TaskBase):
title: str = Field(min_length=1, max_length=200)
status: TaskStatus
priority: TaskPriority
class TaskPatch(BaseModel):
title: str | None = Field(default=None, min_length=1, max_length=200)
description: str | None = None
status: TaskStatus | None = None
priority: TaskPriority | None = None
class TaskResponse(TaskBase):
id: int
created_at: str
updated_at: str | None = None
class TaskListResponse(BaseModel):
status: str = "success"
count: int
data: list[TaskResponse]
class SuccessResponse(BaseModel):
status: str = "success"
message: str
| Decisión | Razón |
|---|---|
TaskBase como clase padre | Evita repetir campos en cada modelo |
TaskUpdate requiere status y priority | PUT reemplaza todo — no hay campos opcionales |
TaskPatch todo opcional | PATCH actualiza solo lo enviado |
Literal para status/priority | Valida strings directamente sin .value |
app/exceptions.py
class TaskNotFoundError(Exception):
def __init__(self, task_id: int):
self.task_id = task_id
self.message = f"Task with id {task_id} not found"
super().__init__(self.message)
class DuplicateTaskError(Exception):
def __init__(self, title: str):
self.title = title
self.message = f"A task with title '{title}' already exists"
super().__init__(self.message)
class InvalidStatusTransitionError(Exception):
def __init__(self, current: str, target: str):
self.current = current
self.target = target
self.message = f"Cannot transition from '{current}' to '{target}'"
super().__init__(self.message)
Estas excepciones son opcionales — puedes usar HTTPException directamente. La ventaja de custom exceptions es que centralizan el formato de error en los handlers en vez de repetirlo en cada endpoint.
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": "2026-01-15T09:00:00Z",
"updated_at": "2026-03-10T14:30:00Z",
},
{
"id": 2,
"title": "Construir To-Do API",
"description": "Proyecto final del módulo 6",
"status": "in_progress",
"priority": "high",
"created_at": "2026-03-10T10:00:00Z",
"updated_at": "2026-03-10T10:00:00Z",
},
{
"id": 3,
"title": "Comprar víveres",
"description": "Frutas, verduras, pan y leche",
"status": "pending",
"priority": "medium",
"created_at": "2026-03-11T08:00:00Z",
"updated_at": "2026-03-11T08:00:00Z",
},
{
"id": 4,
"title": "Revisar correos",
"description": None,
"status": "pending",
"priority": "low",
"created_at": "2026-03-12T07:00:00Z",
"updated_at": "2026-03-12T07:00:00Z",
},
{
"id": 5,
"title": "Preparar presentación",
"description": "Slides para la reunión del lunes sobre el proyecto API",
"status": "pending",
"priority": "high",
"created_at": "2026-03-12T09:30:00Z",
"updated_at": "2026-03-12T09:30:00Z",
},
{
"id": 6,
"title": "Estudiar PostgreSQL",
"description": "Siguiente guía del path backend",
"status": "pending",
"priority": "urgent",
"created_at": "2026-03-13T06:00:00Z",
"updated_at": None,
},
]
id_counter: int = 7
def generate_id() -> int:
global id_counter
current = id_counter
id_counter += 1
return current
def get_current_timestamp() -> str:
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def find_task(task_id: int) -> dict | None:
return next((t for t in tasks if t["id"] == task_id), None)
app/utils.py
from fastapi.responses import JSONResponse
def success_response(message: str, status_code: int = 200) -> JSONResponse:
return JSONResponse(
status_code=status_code,
content={"status": "success", "message": message},
)
def error_response(status_code: int, code: str, message: str) -> JSONResponse:
return JSONResponse(
status_code=status_code,
content={"status": "error", "code": code, "message": message},
)
app/main.py
import logging
import time
from fastapi import FastAPI, HTTPException, Request
from fastapi.exceptions import RequestValidationError
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from app.exceptions import (
TaskNotFoundError,
DuplicateTaskError,
InvalidStatusTransitionError,
)
from app.routes import router
from app.utils import error_response
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
)
logger = logging.getLogger("todo_api")
app = FastAPI(
title="To-Do List API",
description=(
"API REST para gestión de tareas con prioridades y estados.\n\n"
"## Funcionalidades\n\n"
"- **CRUD completo**: crear, listar, obtener, actualizar (PUT/PATCH), eliminar\n"
"- **Filtros**: por estado, prioridad y búsqueda de texto\n"
"- **Paginación**: skip/limit\n"
"- **Validación**: modelos Pydantic v2 con constraints y validators\n"
"- **Error handling**: formato consistente con códigos de error\n"
),
version="1.0.0",
docs_url="/docs",
redoc_url="/redoc",
)
app.add_middleware(
CORSMiddleware,
allow_origins=[
"http://localhost:3000",
"http://localhost:5173",
"http://localhost:5174",
"http://localhost:8080",
],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.middleware("http")
async def timing_middleware(request: Request, call_next):
start = time.time()
response = await call_next(request)
duration_ms = round((time.time() - start) * 1000, 2)
response.headers["X-Process-Time-Ms"] = str(duration_ms)
return response
@app.middleware("http")
async def logging_middleware(request: Request, call_next):
logger.info(f"→ {request.method} {request.url.path}")
response = await call_next(request)
logger.info(f"← {request.method} {request.url.path} → {response.status_code}")
return response
@app.exception_handler(TaskNotFoundError)
async def task_not_found_handler(request: Request, exc: TaskNotFoundError):
logger.warning(f"Task not found: id={exc.task_id} | {request.method} {request.url.path}")
return error_response(404, "TASK_NOT_FOUND", exc.message)
@app.exception_handler(DuplicateTaskError)
async def duplicate_task_handler(request: Request, exc: DuplicateTaskError):
logger.warning(f"Duplicate task: '{exc.title}' | {request.method} {request.url.path}")
return error_response(409, "DUPLICATE_TASK", exc.message)
@app.exception_handler(InvalidStatusTransitionError)
async def invalid_transition_handler(request: Request, exc: InvalidStatusTransitionError):
logger.warning(f"Invalid transition: {exc.current} → {exc.target} | {request.method} {request.url.path}")
return error_response(400, "INVALID_STATUS_TRANSITION", exc.message)
@app.exception_handler(HTTPException)
async def http_exception_handler(request: Request, exc: HTTPException):
logger.warning(f"HTTP {exc.status_code}: {exc.detail} | {request.method} {request.url.path}")
return error_response(exc.status_code, f"HTTP_{exc.status_code}", exc.detail)
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError):
errors = []
for error in exc.errors():
field = " → ".join(str(loc) for loc in error["loc"])
errors.append({"field": field, "message": error["msg"], "type": error["type"]})
logger.warning(f"Validation error: {len(errors)} error(s) | {request.method} {request.url.path}")
return JSONResponse(
status_code=422,
content={
"status": "error",
"code": "VALIDATION_ERROR",
"message": "Request validation failed",
"errors": errors,
},
)
@app.exception_handler(Exception)
async def global_exception_handler(request: Request, exc: Exception):
logger.error(
f"Unhandled error: {type(exc).__name__}: {exc} | {request.method} {request.url.path}",
exc_info=True,
)
return error_response(500, "INTERNAL_ERROR", "An unexpected error occurred. Please try again later.")
app.include_router(router)
app/routes.py
import logging
from datetime import datetime, timezone
from fastapi import APIRouter, HTTPException, Query, Path
from starlette import status
from app.models import (
TaskCreate, TaskUpdate, TaskPatch, TaskResponse, TaskListResponse,
)
from app.data import tasks, generate_id, get_current_timestamp, find_task
logger = logging.getLogger("todo_api")
router = APIRouter()
@router.get("/health", tags=["General"], summary="Health check")
def health_check():
"""Verificar que la API está funcionando."""
return {
"status": "healthy",
"service": "To-Do List API",
"version": "1.0.0",
"timestamp": datetime.now(timezone.utc).isoformat(),
}
@router.post(
"/tasks", response_model=TaskResponse,
status_code=status.HTTP_201_CREATED, tags=["Tasks"], summary="Create a new task",
)
def create_task(task: TaskCreate):
"""Crear una tarea nueva con ID y timestamps auto-generados."""
now = get_current_timestamp()
new_task = {
"id": generate_id(),
**task.model_dump(),
"created_at": now,
"updated_at": now,
}
tasks.append(new_task)
logger.info(f"Task created: id={new_task['id']} title='{new_task['title']}'")
return new_task
@router.get(
"/tasks", response_model=TaskListResponse,
tags=["Tasks"], summary="List tasks with filters and pagination",
)
def list_tasks(
task_status: str | None = Query(default=None, alias="status", description="Filtrar por estado"),
priority: str | None = Query(default=None, description="Filtrar por prioridad"),
search: str | None = Query(default=None, min_length=1, max_length=100, description="Buscar en título y descripción"),
sort_by: str = Query(default="created_at", description="Ordenar por: created_at, priority, status"),
skip: int = Query(default=0, ge=0, description="Resultados a saltar"),
limit: int = Query(default=10, ge=1, le=100, description="Máximo de resultados (1-100)"),
):
"""Listar tareas con filtros opcionales, ordenamiento y paginación."""
results = tasks.copy()
if task_status is not None:
results = [t for t in results if t["status"] == task_status]
if priority is not None:
results = [t for t in results if t["priority"] == priority]
if search is not None:
query = search.lower()
results = [
t for t in results
if query in t["title"].lower()
or (t["description"] and query in t["description"].lower())
]
priority_order = {"urgent": 0, "high": 1, "medium": 2, "low": 3}
if sort_by == "priority":
results.sort(key=lambda t: priority_order.get(t["priority"], 99))
elif sort_by == "status":
results.sort(key=lambda t: t["status"])
else:
results.sort(key=lambda t: t.get("created_at", ""), reverse=True)
paginated = results[skip : skip + limit]
return TaskListResponse(count=len(paginated), data=[TaskResponse(**t) for t in paginated])
@router.get("/tasks/stats", tags=["Tasks"], summary="Task statistics")
def task_stats():
"""Estadísticas agregadas de todas las tareas."""
total = len(tasks)
by_status: dict[str, int] = {}
for t in tasks:
by_status[t["status"]] = by_status.get(t["status"], 0) + 1
by_priority: dict[str, int] = {}
for t in tasks:
by_priority[t["priority"]] = by_priority.get(t["priority"], 0) + 1
completed = by_status.get("completed", 0)
completion_rate = round((completed / total) * 100, 1) if total > 0 else 0.0
return {"status": "success", "total": total, "by_status": by_status, "by_priority": by_priority, "completion_rate": completion_rate}
@router.get("/tasks/{task_id}", response_model=TaskResponse, tags=["Tasks"], summary="Get task by ID")
def get_task(task_id: int = Path(..., ge=1, description="ID de la tarea")):
"""Obtener una tarea por su ID."""
task = find_task(task_id)
if task is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Task with id {task_id} not found")
return task
@router.put("/tasks/{task_id}", response_model=TaskResponse, tags=["Tasks"], summary="Full update of a task")
def update_task(task_data: TaskUpdate, task_id: int = Path(..., ge=1, description="ID de la tarea a actualizar")):
"""Reemplazar todos los campos de una tarea (PUT)."""
task = find_task(task_id)
if task is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Task with id {task_id} not found")
index = tasks.index(task)
tasks[index] = {"id": task_id, **task_data.model_dump(), "created_at": task["created_at"], "updated_at": get_current_timestamp()}
logger.info(f"Task updated (PUT): id={task_id}")
return tasks[index]
@router.patch("/tasks/{task_id}", response_model=TaskResponse, tags=["Tasks"], summary="Partial update of a task")
def patch_task(task_data: TaskPatch, task_id: int = Path(..., ge=1, description="ID de la tarea")):
"""Actualizar solo los campos enviados (PATCH)."""
task = find_task(task_id)
if task is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Task with id {task_id} not found")
update_data = task_data.model_dump(exclude_unset=True)
if not update_data:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="No fields provided for update")
task.update(update_data)
task["updated_at"] = get_current_timestamp()
logger.info(f"Task patched: id={task_id} fields={list(update_data.keys())}")
return task
@router.delete("/tasks/{task_id}", status_code=status.HTTP_204_NO_CONTENT, tags=["Tasks"], summary="Delete a task")
def delete_task(task_id: int = Path(..., ge=1, description="ID de la tarea a eliminar")):
"""Eliminar una tarea por su ID."""
task = find_task(task_id)
if task is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Task with id {task_id} not found")
logger.info(f"Task deleted: id={task_id} title='{task['title']}'")
tasks.remove(task)
app/__init__.py
Vacío — solo marca app/ como paquete Python.
requirements.txt
fastapi>=0.115.0
uvicorn>=0.34.0
3. El README profesional
Este archivo va en la raíz de todo-api/. Un proyecto sin README es un proyecto incompleto:
# To-Do List API
API REST para gestión de tareas construida con FastAPI y Pydantic v2.
## Características
- CRUD completo (POST, GET, PUT, PATCH, DELETE)
- Filtros por estado, prioridad y búsqueda de texto
- Paginación con skip/limit y ordenamiento
- Validación de datos con Pydantic v2
- Error handling con formato consistente
- CORS configurado para desarrollo
- Estadísticas de tareas
- Documentación interactiva automática (/docs)
## Setup
git clone <tu-repo-url>
cd todo-api
python -m venv venv
source venv/bin/activate
pip install -r requirements.txt
## Ejecutar
uvicorn app.main:app --reload
Abre http://127.0.0.1:8000/docs para la documentación interactiva.
## Endpoints
| Método | Ruta | Descripción | Status |
|--------|------------------|--------------------------|---------|
| GET | /health | Health check | 200 |
| POST | /tasks | Crear tarea | 201 |
| GET | /tasks | Listar (filtros + pag.) | 200 |
| GET | /tasks/stats | Estadísticas | 200 |
| GET | /tasks/{id} | Obtener tarea | 200/404 |
| PUT | /tasks/{id} | Actualización completa | 200/404 |
| PATCH | /tasks/{id} | Actualización parcial | 200/404 |
| DELETE | /tasks/{id} | Eliminar tarea | 204/404 |
## Ejemplos
# Crear tarea
curl -X POST http://127.0.0.1:8000/tasks \
-H "Content-Type: application/json" \
-d '{"title": "Mi tarea", "priority": "high"}'
# Listar pendientes
curl "http://127.0.0.1:8000/tasks?status=pending"
# Marcar como completada
curl -X PATCH http://127.0.0.1:8000/tasks/1 \
-H "Content-Type: application/json" \
-d '{"status": "completed"}'
## Formato de errores
{"status": "error", "code": "TASK_NOT_FOUND", "message": "Task with id 999 not found"}
## Tech Stack
- Python 3.10+ / FastAPI / Pydantic v2 / Uvicorn
4. Verificación final
Ejecuta estas 15 pruebas. Cada una verifica un aspecto diferente.
Prerequisito: cd todo-api && source venv/bin/activate && uvicorn app.main:app --reload
| # | Prueba | Comando | Esperado |
|---|---|---|---|
| 1 | Health check | curl -s http://127.0.0.1:8000/health | "status": "healthy", timestamp actual |
| 2 | Listar tareas | curl -s http://127.0.0.1:8000/tasks | "count": 6, 6 tareas con todos los campos |
| 3 | Crear completa | curl -s -w "\nHTTP: %{http_code}\n" -X POST .../tasks -d '{"title":"Test","priority":"high"}' | HTTP 201, id auto-generado |
| 4 | Crear mínima | curl -s -X POST .../tasks -d '{"title":"Solo título"}' | 201, status=pending, priority=medium |
| 5 | Título vacío | curl -s -X POST .../tasks -d '{"title":" "}' | HTTP 422, VALIDATION_ERROR |
| 6 | Status inválido | curl -s -X POST .../tasks -d '{"title":"X","status":"cancelled"}' | HTTP 422 |
| 7 | GET existente | curl -s .../tasks/1 | HTTP 200 con tarea completa |
| 8 | GET inexistente | curl -s .../tasks/999 | HTTP 404, formato de error consistente |
| 9 | Filtros | curl -s ".../tasks?status=pending&priority=high" | Solo tareas pending + high |
| 10 | Búsqueda | curl -s ".../tasks?search=api" | Tareas con "api" en título o descripción |
| 11 | Paginación | curl -s ".../tasks?limit=2" | "count": 2 |
| 12 | PUT | curl -s -X PUT .../tasks/3 -d '{"title":"Nuevo","status":"in_progress","priority":"urgent"}' | 200, updated_at cambió |
| 13 | PATCH + vacío | curl -s -X PATCH .../tasks/2 -d '{"status":"completed"}' luego -d '{}' | 200, luego 400 |
| 14 | DELETE + verificar | curl -s -X DELETE .../tasks/4 luego GET .../tasks/4 | 204, luego 404 |
| 15 | CORS | curl -s -D - -o /dev/null -H "Origin: http://localhost:3000" .../tasks | Header access-control-allow-origin presente |
5. Checklist final
Estructura:
- [ ] todo-api/app/ con main.py, models.py, routes.py, data.py
- [ ] exceptions.py y utils.py presentes
- [ ] __init__.py, requirements.txt, README.md
Modelos Pydantic:
- [ ] TaskBase con title, description, status, priority
- [ ] TaskCreate hereda de TaskBase
- [ ] TaskUpdate con campos requeridos para PUT
- [ ] TaskPatch con todos los campos opcionales (| None)
- [ ] TaskResponse con id, created_at, updated_at
- [ ] TaskListResponse con status, count, data
- [ ] SuccessResponse con status y message
- [ ] @field_validator para title (no whitespace)
Endpoints (8 total):
- [ ] GET /health con timestamp
- [ ] POST /tasks → 201
- [ ] GET /tasks con filtros, paginación, sort
- [ ] GET /tasks/stats con conteos (antes de {task_id})
- [ ] GET /tasks/{task_id} → 200 o 404
- [ ] PUT /tasks/{task_id} → 200 o 404
- [ ] PATCH /tasks/{task_id} → 200, 400, o 404
- [ ] DELETE /tasks/{task_id} → 204 o 404
Error handling:
- [ ] Formato consistente: status, code, message
- [ ] Handlers: TaskNotFoundError, DuplicateTaskError, InvalidStatusTransitionError
- [ ] Handler HTTPException custom
- [ ] Handler RequestValidationError (422) con detalle por campo
- [ ] Handler global Exception → 500
Infraestructura:
- [ ] CORSMiddleware con 4 orígenes de desarrollo
- [ ] Timing middleware (X-Process-Time-Ms)
- [ ] Logging middleware (request/response)
- [ ] /docs con título, descripción Markdown, versión
- [ ] Logging en operaciones CRUD
6. Rúbrica de autoevaluación (100 puntos)
Estructura del proyecto — 10 pts
- (3) Carpeta
app/con archivos separados por responsabilidad - (3)
requirements.txtcon versiones,README.mdcompleto - (2)
__init__.pypresente, imports funcionan - (2) Servidor inicia sin errores
Modelos Pydantic (5 modelos) — 15 pts
- (3)
TaskCreatecon defaults y constraints - (3)
TaskUpdatecon todos los campos requeridos - (3)
TaskPatchcon todos los campos opcionales - (3)
TaskResponsecon id, created_at, updated_at - (3)
TaskListResponsecon metadata
Validators — 5 pts
- (3)
@field_validator("title")rechaza whitespace y hace strip - (2) Usa
@classmethody retorna el valor procesado
Endpoints CRUD — 20 pts
- (4)
POST /tasks→ 201, id y timestamps auto-generados - (4)
GET /tasks→ lista conresponse_model - (4)
GET /tasks/{id}→ 200 o 404 conPath(..., ge=1) - (4)
PUT /tasks/{id}→ reemplaza todo, preservacreated_at - (2)
PATCH /tasks/{id}→exclude_unset=True, 400 body vacío - (2)
DELETE /tasks/{id}→ 204 sin body
Filtros + paginación + sort — 10 pts
- (3) Filtro por status funciona
- (3) Filtro por priority funciona
- (2) Búsqueda por texto case-insensitive
- (2) Paginación skip/limit correcta
Error handling (custom exceptions) — 10 pts
- (3) Formato consistente en todas las respuestas de error
- (3)
RequestValidationErrorhandler con detalle por campo - (2) Handler global
Exception→ 500 genérico - (2) Custom exceptions en archivo separado
CORS + middleware — 5 pts
- (2)
CORSMiddlewarecon orígenes de desarrollo - (2) Timing o logging middleware funciona
- (1) Headers CORS en respuestas de éxito y error
Stats endpoint — 5 pts
- (2)
by_statusyby_prioritycon conteos correctos - (2)
completion_ratecalculado - (1) Ruta antes de
/tasks/{task_id}
/docs personalización — 5 pts
- (2) Título y versión visibles
- (2) Descripción Markdown renderizada
- (1) Tags agrupan endpoints
README quality — 10 pts
- (3) Setup instructions claras
- (3) Tabla de endpoints
- (2) Ejemplos con curl
- (2) Tech stack y formato de errores
Calidad de código — 5 pts
- (2)
model_dump()en toda la codebase (no.dict()) - (2) Type hints en parámetros
- (1) Código limpio
Tu puntaje
Estructura: ___ / 10
Modelos Pydantic: ___ / 15
Validators: ___ / 5
Endpoints CRUD: ___ / 20
Filtros + pag. + sort: ___ / 10
Error handling: ___ / 10
CORS + middleware: ___ / 5
Stats endpoint: ___ / 5
/docs personalización: ___ / 5
README quality: ___ / 10
Calidad de código: ___ / 5
─────────────────────────────────
TOTAL: ___ / 100
| Rango | Nivel |
|---|---|
| 90-100 | Excelente — proyecto profesional listo para tu portafolio |
| 75-89 | Muy bien — funcionalidad completa, algunos detalles menores |
| 60-74 | Bien — CRUD funciona, faltan extras o documentación |
| < 60 | Revisa las cápsulas anteriores y completa lo que falta |
7. Troubleshooting final
Problema 1: ModuleNotFoundError: No module named 'app'
Causa: Estás ejecutando uvicorn desde dentro de app/ en vez de desde todo-api/.
cd todo-api
uvicorn app.main:app --reload
Problema 2: GET /tasks/stats retorna 422
Causa: La ruta /tasks/{task_id} está antes de /tasks/stats en routes.py. FastAPI interpreta "stats" como el valor de task_id.
Solución: Mueve @router.get("/tasks/stats") antes de @router.get("/tasks/{task_id}").
Problema 3: PATCH sobreescribe campos con None
Causa: Falta exclude_unset=True en model_dump().
update_data = task_data.model_dump(exclude_unset=True)
Problema 4: Errores retornan {"detail": "..."} en vez del formato custom
Causa: Los exception handlers no están registrados. Verifica que main.py tiene @app.exception_handler(HTTPException) y los imports incluyen from fastapi import HTTPException, Request.
Problema 5: TypeError: unsupported operand type(s) for |
Causa: Python 3.9 no soporta str | None. Agrega como primera línea de cada archivo:
from __future__ import annotations
8. Reflexión: Tu viaje completo
Mira lo que recorriste. Cada módulo te transformó.
Módulo 1 — Setup y Primera API: Llegaste sin saber qué era FastAPI. Escribiste @app.get("/") y viste tu primer JSON en el navegador. Descubriste /docs. Un {"message": "Hello World"} fue tu punto de partida.
Módulo 2 — Path Operations: Aprendiste que HTTP tiene verbos con significado. Implementaste tu primer CRUD completo. Ya no solo leías datos — los creabas, modificabas y eliminabas.
Módulo 3 — Request y Response: Descubriste path params, query params y request body. Implementaste filtros, búsqueda y paginación. Tu API pasó de "dame todo" a "dame exactamente lo que necesito".
Módulo 4 — Pydantic y Validación: Dejaste los dicts crudos y adoptaste modelos Pydantic. Field(), @field_validator, modelos separados por operación. Tu API empezó a rechazar datos inválidos automáticamente.
Módulo 5 — Error Handling y CORS: Los errores dejaron de ser mentirosos. HTTPException con status codes correctos, exception handlers, formato consistente, CORS para frontends.
Módulo 6 — Todo Integrado: Tomaste cada pieza y la combinaste en un proyecto real. No copiaste un tutorial — aplicaste conocimiento fragmentado para construir algo completo.
La comparación
Día 1 Hoy
───── ───
@app.get("/") 8 endpoints con response_model
return {"msg": "hi"} CRUD + filtros + paginación + stats
Sin validación 5 modelos Pydantic con validators
200 para todo 201, 204, 400, 404, 422, 500
Sin error handling Exception handlers + formato consistente
Sin documentación /docs con título, descripción, tags
Un solo archivo Estructura modular profesional
Sin CORS CORSMiddleware configurado
"¿Qué es FastAPI?" "Sé construir APIs REST con FastAPI"
9. Tu toolkit FastAPI
Todo lo que aprendiste, consolidado:
Framework y Servidor Parámetros
├── FastAPI() ├── Path(...)
├── uvicorn --reload ├── Query(...)
├── /docs, /redoc ├── alias=""
└── APIRouter() └── ge=, le=, min_length=
Decoradores HTTP Pydantic v2
├── @app.get() ├── BaseModel, Field()
├── @app.post() ├── @field_validator
├── @app.put() ├── model_dump()
├── @app.patch() ├── exclude_unset=True
├── @app.delete() ├── Literal["a", "b"]
├── response_model= └── str | None
├── status_code=
└── tags=[], summary="" Status Codes
├── 200, 201, 204
Error Handling ├── 400, 404, 422
├── HTTPException └── 500
├── Custom exceptions
├── @app.exception_handler() Middleware
├── RequestValidationError ├── CORSMiddleware
└── Global handler (500) ├── @app.middleware("http")
└── logging
10. ¿Qué sigue después de esta guía?
FastAPI Advanced Features
- Dependency Injection — Reutilizar lógica entre endpoints con
Depends() - APIRouter avanzado — Prefijos, tags por archivo, modularización real
- WebSockets — Comunicación en tiempo real
- Background Tasks — Operaciones asíncronas
- File Uploads — Subir y servir archivos
- Events y Lifecycle — Startup y shutdown
Bases de datos
- SQLAlchemy — ORM para Python
- PostgreSQL — Base de datos relacional
- Alembic — Migraciones de esquema
- Async SQLAlchemy — Conexiones asíncronas con FastAPI
Autenticación
- Password hashing con bcrypt
- JWT tokens para sesiones stateless
- OAuth2 integrado con FastAPI Security
- RBAC — Roles y permisos
Testing
- pytest — Framework de testing
- httpx — Cliente HTTP async
- TestClient de FastAPI — Tests de integración
- Coverage — Medir cobertura
Deployment
- Docker — Containerizar tu app
- Docker Compose — Multi-container
- Cloud — Railway, Render, AWS, GCP
Cada uno de estos temas es una guía completa en el path Backend Python Developer. Los fundamentals que aprendiste aquí son la base sobre la que todo se construye.
11. Resumen final
Las 10 cosas más importantes que aprendiste:
-
FastAPI genera documentación automática —
/docsy/redocse actualizan con cada endpoint. No escribes docs manualmente. -
Cada verbo HTTP tiene un propósito — GET lee, POST crea, PUT reemplaza, PATCH actualiza parcialmente, DELETE elimina.
-
Pydantic valida automáticamente — Defines un modelo y FastAPI valida el request body. Datos inválidos nunca llegan a tu lógica.
-
Modelos separados por operación —
TaskCreatepara POST,TaskUpdatepara PUT,TaskPatchpara PATCH,TaskResponsepara respuestas. -
exclude_unset=Truees la clave de PATCH — Sin esto, los campos no enviados se serializan comoNoney sobreescriben datos existentes. -
Los errores deben comunicar, no mentir — 404 para "no existe", 400 para "datos incorrectos", 422 para "validación falló".
-
Exception handlers centralizan el formato — Un handler por tipo de error mantiene consistencia automática.
-
CORS es obligatorio para frontends — Sin
CORSMiddleware, ningún browser puede consumir tu API desde otro dominio. -
El orden de rutas importa — Rutas fijas (
/tasks/stats) van antes de rutas con parámetros (/tasks/{task_id}). -
La estructura refleja profesionalismo — Archivos separados por responsabilidad facilitan mantenimiento y trabajo en equipo.
12. Recursos adicionales
- FastAPI - Tutorial completo — Referencia oficial de todos los conceptos
- Pydantic v2 - Documentación — Modelos, validators y serialización
- FastAPI - Bigger Applications — Estructura de proyectos grandes
- FastAPI - Testing — Tests con TestClient y pytest
- HTTP Status Codes - MDN — Referencia completa de status codes
- Real Python - FastAPI — Tutorial práctico paso a paso
13. Cierre
Construiste tu primera API profesional.
No una API de tutorial que solo repite lo que dice el instructor. Una API que tú diseñaste, implementaste y verificaste. Con modelos Pydantic que validan datos antes de que lleguen a tu código. Con error handling que comunica errores de forma predecible. Con filtros, paginación, estadísticas y documentación automática. Con CORS configurado para que cualquier frontend pueda consumirla.
Eso es tuyo. Nadie te lo puede quitar.
Ponla en tu GitHub. Escribe un README claro. Cuando alguien te pregunte "¿sabes hacer APIs?", no necesitas explicar — les mandas el link. Los fundamentals están dominados. Lo que viene — bases de datos, autenticación, testing, deployment — se construye sobre exactamente lo que acabas de demostrar que puedes hacer.
Guía completada. A construir lo que sigue.