Module 6: Project — CRUD API (To-Do List)

Paso 4: Error Handling, CORS y Documentación

Descripción

Esta cápsula cierra el proyecto To-Do List API añadiendo la capa de pulido profesional que distingue una API funcional de una lista para portfolio. Hasta ahora tienes CRUD completo, filtros, paginación y validación con Pydantic; pero si un frontend consume tu API, encontrará respuestas de error con formatos distintos, el navegador bloqueará las peticiones por CORS, y la documentación en /docs será genérica.

Aquí vas a unificar todos los errores en un solo formato JSON, configurar CORS para que tu frontend en localhost:3000 pueda llamar a localhost:8000, y enriquecer la metadata de OpenAPI para que /docs y /redoc muestren una API bien documentada. Es el último paso antes de la entrega final del módulo.

Las técnicas que verás aquí (exception handlers, CORS, metadata) son estándar en APIs REST profesionales; dominarlas te permite presentar tu proyecto con confianza en entrevistas y portfolio.


Objetivos de esta cápsula

  • Exception handlers personalizados con formato JSON consistente
  • CORS middleware configurado
  • Metadata de la API (title, description, version, tags)
  • Pulido final y verificación completa

El problema: respuestas de error inconsistentes

Antes de añadir handlers personalizados, FastAPI devuelve errores con formatos distintos según el tipo de fallo.

HTTPException (404, 400, etc.):

{
  "detail": "Task 999 not found"
}

RequestValidationError (422 — validación Pydantic):

{
  "detail": [
    {
      "type": "string_too_short",
      "loc": ["body", "title"],
      "msg": "String should have at least 1 character",
      "ctx": {"min_length": 1},
      "input": ""
    }
  ]
}

Para el frontend esto es un dolor de cabeza: en un caso recibe detail como string, en otro como array de objetos con estructura anidada. Cada desarrollador tendría que escribir lógica distinta para cada tipo de error.

Un formato consistente significa que siempre hay success: false, un error.code legible y un error.message humano. Así el cliente puede mostrar el mensaje al usuario y usar el código para lógica condicional (por ejemplo, mostrar un modal de “Revisa los campos” cuando sea VALIDATION_ERROR).


Formato de error consistente

En lugar de que cada HTTPException retorne un body distinto, definimos un formato estándar:

{
  "success": false,
  "error": {
    "code": "NOT_FOUND",
    "message": "Task 999 not found"
  },
  "status_code": 404
}

Beneficios:

  • ✅ El cliente sabe siempre cómo parsear errores
  • ✅ Códigos de error legibles para debugging
  • ✅ status_code explícito en el body (además del header HTTP)

Exception handler para HTTPException

FastAPI permite registrar un handler que captura HTTPException antes de enviar la respuesta:

from fastapi import Request
from fastapi.responses import JSONResponse
from fastapi.exceptions import RequestValidationError

@app.exception_handler(HTTPException)
async def http_exception_handler(request: Request, exc: HTTPException):
    return JSONResponse(
        status_code=exc.status_code,
        content={
            "success": False,
            "error": {
                "code": _status_to_code(exc.status_code),
                "message": exc.detail if isinstance(exc.detail, str) else str(exc.detail),
            },
            "status_code": exc.status_code,
        },
    )

def _status_to_code(status: int) -> str:
    m = {404: "NOT_FOUND", 400: "BAD_REQUEST", 409: "CONFLICT", 422: "VALIDATION_ERROR"}
    return m.get(status, "ERROR")

Si el detail es un dict (FastAPI a veces lo usa para 422), convertimos a string para mantener consistencia.


El helper _status_to_code

El diccionario completo que mapea códigos HTTP a identificadores legibles es:

Código HTTPCódigo internoCuándo se usa en esta API
404NOT_FOUNDTarea no existe en GET/PUT/PATCH/DELETE por id
400BAD_REQUESTPetición malformada o datos inválidos en reglas de negocio
409CONFLICTConflictos (ej. crear tarea duplicada, si lo implementas)
422VALIDATION_ERRORFallback si HTTPException se lanza con 422
OtroERRORCualquier otro código no contemplado

El m.get(status, "ERROR") garantiza que nunca devuelvas None: si añades un HTTPException(status_code=500), el cliente seguirá recibiendo un code válido.


Handler para RequestValidationError (422)

Los errores de validación de Pydantic llegan como RequestValidationError. Puedes formatearlos también:

@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError):
    errors = exc.errors()
    messages = [f"{e.get('loc', [])[-1]}: {e.get('msg', '')}" for e in errors]
    return JSONResponse(
        status_code=422,
        content={
            "success": False,
            "error": {
                "code": "VALIDATION_ERROR",
                "message": "; ".join(messages),
                "details": errors,
            },
            "status_code": 422,
        },
    )

Así mantienes un formato consistente incluso para errores 422 automáticos.

Qué lo dispara: Cualquier petición donde el body o los query params no cumplan los modelos Pydantic. Por ejemplo, POST /tasks con {"title": ""} o {"status": "invalid"} dispara este handler.

Qué hace el handler: exc.errors() devuelve una lista de diccionarios con loc (ruta del campo), msg, type, etc. Extraemos el último elemento de loc (el nombre del campo) y el mensaje, los unimos en un string legible para message, y dejamos details con la estructura cruda por si el cliente quiere mostrar errores por campo.

Ejemplo: Si envías POST /tasks con {"title": "", "status": "invalid"} obtienes algo como:

{
  "success": false,
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "title: String should have at least 1 character; status: Input should be 'pending', 'in_progress' or 'completed'",
    "details": [...]
  },
  "status_code": 422
}

CORS Middleware

Para que un frontend en otro dominio (ej. localhost:3000) pueda llamar a tu API, necesitas CORS:

from fastapi.middleware.cors import CORSMiddleware

app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

En desarrollo, allow_origins=["*"] permite cualquier origen. En producción definirías orígenes concretos, por ejemplo ["https://mi-app.com"].


¿Por qué necesitas CORS?

Los navegadores aplican la política de same-origin: una página en https://mi-app.com solo puede hacer peticiones a https://mi-app.com sin restricciones. Si tu API está en http://localhost:8000 y tu frontend en http://localhost:3000, son orígenes distintos (puerto diferente), así que el navegador bloquea las peticiones por seguridad.

Sin CORS, en la consola verías: Access to fetch at 'http://localhost:8000/tasks' from origin 'http://localhost:3000' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the response.

Configuración desarrollo vs producción: En desarrollo allow_origins=["*"] es práctico para probar con cualquier puerto. En producción debes restringir: allow_origins=["https://mi-app.com", "https://www.mi-app.com"] para evitar que sitios externos consuman tu API desde el navegador.

Peticiones preflight (OPTIONS): Para peticiones que no son “simples” (por ejemplo, con Content-Type: application/json o métodos distintos de GET/POST básicos), el navegador envía primero una petición OPTIONS para preguntar qué métodos y headers permites. El middleware CORS responde automáticamente con los headers adecuados. Con allow_methods=["*"] y allow_headers=["*"] permites GET, POST, PUT, PATCH, DELETE y headers como Authorization o Content-Type.


Metadata de la API

Mejorar title, description y agregar tags mejora la documentación en /docs y /redoc:

app = FastAPI(
    title="To-Do List API",
    description="""
    API CRUD completa para gestionar tareas. Incluye:
    - CRUD completo (GET, POST, PUT, PATCH, DELETE)
    - Filtros por status y priority
    - Búsqueda por texto en título
    - Ordenamiento y paginación
    - Validación con Pydantic
    - Error handling profesional y CORS
    """,
    version="1.0.0",
    docs_url="/docs",
    redoc_url="/redoc",
)

Tags en los endpoints agrupan la documentación:

@app.get("/tasks", response_model=TaskListResponse, tags=["Tasks"])
def list_tasks(...):
    ...

@app.get("/tasks/{task_id}", response_model=TaskResponse, tags=["Tasks"])
def get_task(...):
    ...

Parámetros del constructor FastAPI y documentación

title y description: Aparecen en la parte superior de Swagger UI (/docs) y ReDoc (/redoc). Una descripción clara ayuda a quien pruebe la API por primera vez.

version: Se refleja en el esquema OpenAPI; útil cuando versionas la API (v1, v2).

docs_url y redoc_url: Rutas de la documentación interactiva. Puedes usar docs_url=None para desactivar Swagger en producción si lo prefieres.

Tags: Agrupan endpoints en secciones. Puedes definir tags y openapi_tags en el constructor para dar descripciones a cada grupo:

app = FastAPI(
    ...
    openapi_tags=[
        {"name": "Tasks", "description": "CRUD de tareas"},
        {"name": "Health", "description": "Estado del servicio"},
    ],
)

Summary y description por endpoint: El docstring de la función se usa como descripción del endpoint en OpenAPI. Por ejemplo:

@app.post("/tasks", status_code=201, response_model=TaskResponse, tags=["Tasks"])
def create_task(task: TaskCreate):
    """Crea una nueva tarea. Requiere al menos título con 1-200 caracteres."""

También puedes usar el parámetro summary en el decorador para un título breve.

Cómo se ve /docs con buena metadata: Imagina abrir http://localhost:8000/docs en el navegador. En la parte superior aparece el título "To-Do List API" en grande, seguido de la versión "1.0.0". Debajo, un bloque con la descripción completa en Markdown: CRUD, filtros, búsqueda, ordenamiento, paginación, validación y error handling, cada uno en su propia línea. A la izquierda, un menú colapsable con secciones por tag: "Health" agrupa GET /, y "Tasks" agrupa los siete endpoints de tareas (list_tasks, get_task, create_task, etc.). Al hacer clic en "GET /tasks", se expande un panel con los parámetros de query (status, priority, search, sort_by, order, skip, limit), cada uno con su descripción y tipo. Para POST /tasks aparece el esquema TaskCreate con los campos requeridos y opcionales. En la parte inferior de cada endpoint hay un botón "Try it out" que permite ejecutar la petición contra tu servidor local y ver la respuesta en tiempo real. Todo esto sale automáticamente de la metadata que configuraste; no escribes HTML ni CSS.

ReDoc (/redoc): La misma metadata alimenta ReDoc, una interfaz alternativa más orientada a lectura. En lugar del diseño expandible de Swagger, ReDoc muestra una columna lateral con todos los endpoints y un panel principal donde se ve el detalle del endpoint seleccionado. Es útil para documentación impresa o para equipos que prefieren una vista más lineal.


Código completo final

Este es el app/main.py con modelos, base de datos, helpers, exception handlers, CORS, endpoints y metadata integrados. Úsalo como referencia del proyecto completo:

from datetime import datetime
from typing import Literal, Optional

from fastapi import FastAPI, HTTPException, Query, Request
from fastapi.exceptions import RequestValidationError
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field


# --- Modelos Pydantic ---

StatusType = Literal["pending", "in_progress", "completed"]
PriorityType = Literal["low", "medium", "high"]


class TaskCreate(BaseModel):
    title: str = Field(min_length=1, max_length=200, description="Título de la tarea")
    description: str = Field(default="", max_length=1000, description="Descripción opcional")
    status: StatusType = Field(default="pending", description="Estado de la tarea")
    priority: PriorityType = Field(default="medium", description="Prioridad")


class TaskUpdate(BaseModel):
    title: Optional[str] = Field(default=None, min_length=1, max_length=200)
    description: Optional[str] = Field(default=None, max_length=1000)
    status: Optional[StatusType] = None
    priority: Optional[PriorityType] = None


class TaskResponse(BaseModel):
    id: int
    title: str
    description: str
    status: str
    priority: str
    created_at: datetime

    model_config = {"from_attributes": True}


class TaskListResponse(BaseModel):
    total: int
    skip: int
    limit: int
    items: list[TaskResponse]


# --- Base de datos y utilidades ---

PRIORITY_ORDER = {"high": 0, "medium": 1, "low": 2}


def _parse_dt(s: str) -> datetime:
    return datetime.fromisoformat(s.replace("Z", "+00:00"))


tasks_db: list[dict] = [
    {
        "id": 1,
        "title": "Configurar proyecto FastAPI",
        "description": "Crear estructura y dependencias",
        "status": "completed",
        "priority": "high",
        "created_at": _parse_dt("2025-03-01T10:00:00"),
    },
    {
        "id": 2,
        "title": "Implementar CRUD de tareas",
        "description": "GET, POST, PUT, PATCH, DELETE",
        "status": "in_progress",
        "priority": "high",
        "created_at": _parse_dt("2025-03-02T09:30:00"),
    },
    {
        "id": 3,
        "title": "Agregar filtros y paginación",
        "description": "",
        "status": "pending",
        "priority": "medium",
        "created_at": _parse_dt("2025-03-03T14:00:00"),
    },
]


def next_id() -> int:
    return max((t["id"] for t in tasks_db), default=0) + 1


def find_task(task_id: int) -> dict | None:
    return next((t for t in tasks_db if t["id"] == task_id), None)


def _status_to_code(status: int) -> str:
    m = {404: "NOT_FOUND", 400: "BAD_REQUEST", 409: "CONFLICT", 422: "VALIDATION_ERROR"}
    return m.get(status, "ERROR")


# --- App FastAPI ---

app = FastAPI(
    title="To-Do List API",
    description="""
    API CRUD completa para gestionar tareas. Incluye:

    - **CRUD completo:** GET, POST, PUT, PATCH, DELETE
    - **Filtros:** por status (pending, in_progress, completed) y priority (low, medium, high)
    - **Búsqueda:** texto en título (case insensitive)
    - **Ordenamiento:** por created_at, priority o title
    - **Paginación:** skip y limit
    - **Validación:** modelos Pydantic con Field constraints
    - **Error handling:** formato consistente y CORS
    """,
    version="1.0.0",
    docs_url="/docs",
    redoc_url="/redoc",
)

app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)


@app.exception_handler(HTTPException)
async def http_exception_handler(request: Request, exc: HTTPException):
    detail = exc.detail
    if isinstance(detail, dict):
        detail = str(detail)
    return JSONResponse(
        status_code=exc.status_code,
        content={
            "success": False,
            "error": {
                "code": _status_to_code(exc.status_code),
                "message": detail,
            },
            "status_code": exc.status_code,
        },
    )


@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError):
    errors = exc.errors()
    simplified = []
    for e in errors:
        loc = e.get("loc", [])
        field = loc[-1] if loc else "body"
        simplified.append(f"{field}: {e.get('msg', '')}")
    return JSONResponse(
        status_code=422,
        content={
            "success": False,
            "error": {
                "code": "VALIDATION_ERROR",
                "message": "; ".join(simplified),
                "details": errors,
            },
            "status_code": 422,
        },
    )


@app.get("/", tags=["Health"])
def root():
    return {
        "service": "To-Do List API",
        "version": "1.0.0",
        "total_tasks": len(tasks_db),
    }


@app.get("/tasks", response_model=TaskListResponse, tags=["Tasks"])
def list_tasks(
    status: Optional[str] = Query(None, description="Filtrar por estado"),
    priority: Optional[str] = Query(None, description="Filtrar por prioridad"),
    search: Optional[str] = Query(None, description="Buscar en título"),
    sort_by: Literal["created_at", "priority", "title"] = Query(
        default="created_at", description="Campo para ordenar"
    ),
    order: Literal["asc", "desc"] = Query(default="desc", description="Orden"),
    skip: int = Query(0, ge=0, description="Offset"),
    limit: int = Query(10, ge=1, le=100, description="Límite por página"),
):
    result = list(tasks_db)
    if status is not None:
        result = [t for t in result if t.get("status") == status]
    if priority is not None:
        result = [t for t in result if t.get("priority") == priority]
    if search is not None and search.strip():
        search_lower = search.lower().strip()
        result = [t for t in result if search_lower in t.get("title", "").lower()]

    total = len(result)
    reverse = order.lower() == "desc"
    if sort_by == "created_at":
        result.sort(key=lambda t: t.get("created_at", ""), reverse=reverse)
    elif sort_by == "priority":
        result.sort(
            key=lambda t: PRIORITY_ORDER.get(t.get("priority", ""), 3),
            reverse=reverse,
        )
    elif sort_by == "title":
        result.sort(key=lambda t: t.get("title", "").lower(), reverse=reverse)

    paginated = result[skip : skip + limit]
    return TaskListResponse(total=total, skip=skip, limit=limit, items=paginated)


@app.get("/tasks/{task_id}", response_model=TaskResponse, tags=["Tasks"])
def get_task(task_id: int):
    task = find_task(task_id)
    if task is None:
        raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
    return task


@app.post("/tasks", status_code=201, response_model=TaskResponse, tags=["Tasks"])
def create_task(task: TaskCreate):
    data = task.model_dump()
    data["id"] = next_id()
    data["created_at"] = datetime.utcnow()
    tasks_db.append(data)
    return data


@app.put("/tasks/{task_id}", response_model=TaskResponse, tags=["Tasks"])
def update_task(task_id: int, task: TaskCreate):
    existing = find_task(task_id)
    if existing is None:
        raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
    updated = task.model_dump()
    updated["id"] = task_id
    updated["created_at"] = existing["created_at"]
    idx = tasks_db.index(existing)
    tasks_db[idx] = updated
    return updated


@app.patch("/tasks/{task_id}", response_model=TaskResponse, tags=["Tasks"])
def partial_update_task(task_id: int, updates: TaskUpdate):
    existing = find_task(task_id)
    if existing is None:
        raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
    update_data = updates.model_dump(exclude_unset=True)
    existing.update(update_data)
    return existing


@app.delete("/tasks/{task_id}", tags=["Tasks"])
def delete_task(task_id: int):
    task = find_task(task_id)
    if task is None:
        raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
    tasks_db.remove(task)
    return {"message": "Task deleted", "id": task_id}

Verificación completa

Sigue este flujo de 10 pasos para comprobar que todo funciona de punta a punta:

  1. Levanta la API con uvicorn app.main:app --reload.
  2. GET http://localhost:8000/ — Debes ver el nombre del servicio, versión y total de tareas.
  3. GET http://localhost:8000/tasks — Lista de tareas con paginación por defecto.
  4. GET http://localhost:8000/tasks?status=completed — Filtro por status.
  5. GET http://localhost:8000/tasks/1 — Una tarea concreta.
  6. GET http://localhost:8000/tasks/999 — 404 con formato {"success": false, "error": {"code": "NOT_FOUND", ...}}.
  7. POST http://localhost:8000/tasks con body {"title": ""} — 422 con formato consistente y code: "VALIDATION_ERROR".
  8. POST http://localhost:8000/tasks con body válido — 201 y tarea creada.
  9. Abre http://localhost:8000/docs — Swagger con título, descripción, tags y endpoints agrupados.
  10. Si tienes un frontend en otro puerto, llama a la API desde el navegador y comprueba que CORS permite la petición.

Cómo ejecutar las pruebas: Puedes usar la pestaña "Try it out" en Swagger (/docs) para cada endpoint, o usar curl desde la terminal. Para el 404: curl http://localhost:8000/tasks/999. Para el 422: curl -X POST http://localhost:8000/tasks -H "Content-Type: application/json" -d '{"title":""}'. Compara las respuestas con el formato esperado (success, error.code, status_code). Para probar CORS necesitas un frontend real en otro puerto o una extensión de navegador que simule otro origen.


Errores comunes

  1. Registrar los handlers después de definir rutas — Los handlers deben registrarse después de crear app pero antes de definir rutas, o al menos antes de que se procese la primera petición. Si los defines al final, puede haber comportamientos inesperados.
  2. Olvidar Request en el handler — Aunque no uses request, FastAPI lo requiere para inyectar la petición; sin él el handler falla.
  3. CORS con allow_origins=["*"] y allow_credentials=True — En teoría no se recomienda combinar ambos, pero en muchos entornos de desarrollo funciona. En producción usa orígenes explícitos.
  4. No manejar detail como dict — Si lanzas HTTPException(detail={"key": "value"}), convertir a string evita que JSONResponse falle o devuelva algo raro.
  5. Orden del middleware — add_middleware añade en orden inverso de ejecución. CORS suele ir primero (último en añadirse) para que se ejecute antes que otros middlewares en la cadena de request. Si añades varios middlewares (por ejemplo, logging o rate limiting), el último que registres será el primero en ejecutarse en la fase de request.

Troubleshooting

  1. Sigo viendo el formato antiguo de errores: Comprueba que los handlers están registrados y que no hay otro middleware o código que devuelva respuestas antes. Reinicia uvicorn.
  2. CORS sigue bloqueando: Verifica que CORSMiddleware está añadido. Revisa en DevTools (Network) si la petición OPTIONS devuelve 200 y los headers Access-Control-Allow-Origin y Access-Control-Allow-Methods.
  3. 422 con formato raro: Asegúrate de tener el handler de RequestValidationError registrado. Si usas ambos handlers, el de RequestValidationError debe capturar los 422 de validación antes que el de HTTPException (FastAPI los distingue por tipo de excepción).
  4. /docs no carga o da 404: Confirma que docs_url="/docs" está en el constructor de FastAPI y que no hay una ruta que capture /docs antes.

Consejos para producción

Cuando despliegues la API en un entorno real, considera estos ajustes:

Orígenes CORS concretos: En lugar de allow_origins=["*"], lista explícitamente los dominios de tu frontend. Por ejemplo: allow_origins=["https://mi-app.com", "https://www.mi-app.com"]. Así evitas que sitios de terceros consuman tu API desde el navegador.

Desactivar documentación en producción: Si prefieres no exponer /docs ni /redoc en producción, usa docs_url=None y redoc_url=None. La documentación seguirá generándose en el esquema OpenAPI (por ejemplo en /openapi.json) para herramientas externas, pero no habrá interfaz web pública.

Logging de errores: En los exception handlers puedes añadir logging.error(...) para registrar errores en un sistema de logs. Así podrás diagnosticar problemas en producción sin exponer detalles sensibles al cliente.

Rate limiting: Más adelante, en el módulo de FastAPI avanzado, verás cómo combinar CORS con middleware de rate limiting para proteger tu API de abuso. Por ahora, tener CORS bien configurado ya reduces superficie de ataque.


Resumen

En esta cápsula has añadido la capa final de calidad a la API: un formato de error unificado para HTTPException y RequestValidationError, CORS para consumo desde frontends en otros orígenes, y metadata enriquecida para documentación. Con esto, tu To-Do List API está lista para portfolio y para ser consumida por una aplicación frontend real.


Recursos adicionales


Checklist

  • ✅ Exception handler para HTTPException
  • ✅ Exception handler para RequestValidationError
  • ✅ Formato de error consistente (success, error, status_code)
  • ✅ CORS middleware configurado
  • ✅ Metadata de API en FastAPI()
  • ✅ Tags en endpoints para agrupar en /docs

Próximo paso

En la Cápsula 06 verás la entrega final: código completo, rúbrica de evaluación, errores comunes, qué viene después (FastAPI Advanced Features) y retrospectiva 4Ls.


Módulo 6, Cápsula 05 — FastAPI Fundamentals Guide