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

Paso 2: CRUD Endpoints

Descripción

CRUD es el acrónimo de Create, Read, Update y Delete, las cuatro operaciones fundamentales sobre datos persistentes. En la práctica, cualquier API que trabaje con recursos (usuarios, productos, tareas, etc.) necesita exponer estas operaciones de forma coherente y predecible. Cada verbo HTTP tiene un significado bien definido: GET para leer, POST para crear, PUT para reemplazar un recurso completo, PATCH para actualizar solo algunos campos, y DELETE para eliminar.

En esta cápsula implementas todos los endpoints CRUD de la API To-Do: GET por ID, POST (crear), PUT (actualizar completo), PATCH (actualizar parcial) y DELETE (eliminar). Cada endpoint usa los modelos Pydantic definidos en la cápsula anterior, retorna los status codes apropiados (200, 201, 404) y maneja el recurso inexistente con HTTPException. Aprenderás no solo la sintaxis, sino el razonamiento detrás de cada decisión: por qué usar raise y no return para los errores, cuándo preservar metadatos como created_at y por qué exclude_unset es crítico en PATCH.


Objetivos de esta cápsula

  • GET /tasks/{task_id} — Obtener una tarea por ID
  • POST /tasks — Crear una tarea (201 Created)
  • PUT /tasks/{task_id} — Actualizar tarea completa
  • PATCH /tasks/{task_id} — Actualizar campos específicos
  • DELETE /tasks/{task_id} — Eliminar tarea
  • Usar HTTPException para 404 (recurso no encontrado)

Especificación de endpoints

MétodoRutaDescripciónStatus codes
GET/tasksLista todas (ya implementado)200
GET/tasks/{task_id}Obtener una tarea200, 404
POST/tasksCrear tarea201, 422
PUT/tasks/{task_id}Actualizar completa200, 404, 422
PATCH/tasks/{task_id}Actualizar parcial200, 404, 422
DELETE/tasks/{task_id}Eliminar200, 404

GET /tasks/{task_id}

Contexto: patrón "find or 404"

En APIs REST, cuando un cliente solicita un recurso por ID y este no existe, la convención es retornar 404 Not Found. En FastAPI no debes hacer return de una respuesta de error: debes lanzar una excepción con raise HTTPException(...). ¿Por qué? Porque return simplemente devuelve un valor normal al framework; raise interrumpe la ejecución y permite que FastAPI capture la excepción, la convierta en la respuesta HTTP adecuada y aplique el status code correcto. Si usaras return {"error": "not found"}, tendrías que definir manualmente el status 404 y el formato de la respuesta; con raise HTTPException, todo viene ya definido y consistente.

Implementación

Busca la tarea por ID. Si no existe, lanza HTTPException 404.

from fastapi import HTTPException

@app.get("/tasks/{task_id}", response_model=TaskResponse)
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

Prueba en /docs

  1. Abre http://localhost:8000/docs, localiza GET /tasks/{task_id} y haz clic en "Try it out".
  2. Ingresa task_id: 1 → "Execute". Deberías recibir 200 con un JSON como:
    {
      "id": 1,
      "title": "Configurar proyecto FastAPI",
      "description": "Crear estructura y dependencias",
      "status": "completed",
      "priority": "high",
      "created_at": "2025-03-01T10:00:00"
    }
  3. Ahora prueba task_id: 999 → "Execute". Recibirás 404 con algo como:
    {
      "detail": "Task 999 not found"
    }

POST /tasks

Contexto: model_dump(), id y created_at en el servidor

Para crear un recurso, el cliente envía los datos (title, description, status, priority) en el body. Pero el servidor debe ser quien asigne el id y el created_at: si el cliente pudiera elegirlos, podrían generarse colisiones o timestamps incorrectos. Por eso usamos task.model_dump() para obtener un diccionario con los campos del body y luego añadimos id y created_at en el servidor antes de guardar. El status 201 Created indica al cliente que el recurso fue creado exitosamente; es la convención REST correcta y ayuda a los clientes a distinguir una creación de una simple operación exitosa (200).

Implementación

Crea una nueva tarea. Asigna id con next_id(), created_at con datetime.utcnow(), y agrega a tasks_db. Retorna 201 Created.

from datetime import datetime

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

Validación automática: si el cliente envía title vacío, status inválido o priority inválido, FastAPI retorna 422 antes de ejecutar la función.

Prueba en /docs

  1. Localiza POST /tasks en /docs y haz clic en "Try it out".
  2. Usa este body de ejemplo (o solo title si quieres probar los defaults):
    {
      "title": "Revisar documentación de la API",
      "description": "Validar que /docs muestre todos los endpoints",
      "status": "pending",
      "priority": "medium"
    }
  3. "Execute". Respuesta esperada 201 con el recurso creado:
    {
      "id": 4,
      "title": "Revisar documentación de la API",
      "description": "Validar que /docs muestre todos los endpoints",
      "status": "pending",
      "priority": "medium",
      "created_at": "2025-03-14T12:30:00"
    }
    Observa que id y created_at vienen generados por el servidor.

PUT /tasks/{task_id}

Contexto: semántica de reemplazo completo y preservar created_at

PUT significa "reemplazar el recurso completo". El cliente envía todos los campos requeridos (como en TaskCreate) y el servidor sustituye el recurso existente por esos datos. No es una actualización parcial: es un reemplazo total. Por eso debes preservar metadatos generados por el servidor que no forman parte del body, como created_at. Si los sobrescribieras, perderías la fecha original de creación, lo cual no tiene sentido desde el punto de vista semántico. PUT se usa cuando el cliente tiene la representación completa del recurso y quiere reemplazarla; PATCH se reserva para actualizaciones parciales cuando solo se modifican algunos campos.

Implementación

Reemplaza la tarea completa. El body es TaskCreate (todos los campos requeridos). Si no existe, 404.

@app.put("/tasks/{task_id}", response_model=TaskResponse)
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

Importante: preservar created_at del recurso original — no debe cambiar en PUT.

Prueba en /docs

  1. Localiza PUT /tasks/{task_id} y haz clic en "Try it out".
  2. Usa task_id: 4 (o el id de una tarea que hayas creado con POST).
  3. El body debe incluir todos los campos (PUT exige representación completa):
    {
      "title": "Revisar documentación (actualizado)",
      "description": "Completado el checklist",
      "status": "completed",
      "priority": "high"
    }
  4. "Execute". Deberías recibir 200 con el recurso actualizado. Verifica que created_at no haya cambiado respecto al valor original.

PATCH /tasks/{task_id}

Contexto: actualización parcial y exclude_unset

PATCH permite actualizar solo los campos que el cliente envía. Si el cliente manda {"status": "completed"}, solo status debe cambiar; el resto (title, description, priority) debe permanecer intacto. Aquí entra en juego model_dump(exclude_unset=True): Pydantic distingue entre "campo con valor None porque el cliente lo envió explícitamente" y "campo no incluido en el body". Con exclude_unset=True, solo se incluyen los campos que el cliente realmente mandó. Sin eso, updates.model_dump() devolvería algo como {"title": None, "description": None, "status": "completed", "priority": None}, y al hacer existing.update(update_data) sobrescribirías todos los campos con None excepto status. Hay un caso límite: si el cliente envía un body vacío {}, exclude_unset=True devuelve un dict vacío y existing.update({}) no modifica nada, que es el comportamiento correcto.

Implementación

Actualización parcial. El body es TaskUpdate (todos los campos opcionales). Solo actualizas los campos que el cliente envía.

@app.patch("/tasks/{task_id}", response_model=TaskResponse)
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

model_dump(exclude_unset=True) retorna solo los campos que el cliente envió. Si envía {"status": "completed"}, solo status se actualiza; el resto permanece igual.

Prueba en /docs

  1. Localiza PATCH /tasks/{task_id} y haz clic en "Try it out".
  2. Usa task_id: 4 y un body parcial:
    {
      "status": "completed"
    }
  3. "Execute". 200 con el recurso actualizado. Verifica que solo status cambió; title, description y priority se mantienen.
  4. Prueba también con body vacío {}: no debe cambiar nada.

PUT vs PATCH: Comparación directa

AspectoPUTPATCH
SemánticaReemplazo completo del recursoActualización parcial (solo campos enviados)
BodyDebe incluir todos los campos requeridos (TaskCreate)Solo los campos que quieres cambiar (TaskUpdate, todos opcionales)
Campos no enviadosN/A: el cliente envía todoSe ignoran; el recurso conserva sus valores actuales
created_atDebe preservarse del recurso originalNo aplica: normalmente no se toca
Caso de usoCliente tiene la representación completa y quiere reemplazarlaCliente quiere cambiar uno o pocos campos (ej. marcar como completada)
Implementacióntask.model_dump() + preservar created_at + reemplazar en la listaupdates.model_dump(exclude_unset=True) + existing.update(update_data)

DELETE /tasks/{task_id}

Contexto: remove() vs pop() e idempotencia

Para eliminar un elemento de la lista usamos tasks_db.remove(task) porque tenemos la referencia al objeto exacto que queremos quitar. remove() busca por identidad/referencia (en este caso, el dict retornado por find_task es el mismo que está en tasks_db). pop(index) también funcionaría si conocieras el índice, pero remove(task) es más directo cuando ya tienes el objeto. Sobre idempotencia: según HTTP, si el recurso ya no existe, DELETE puede retornar 404 o 204. Retornar 404 es coherente ("no hay nada que eliminar"), pero algunos diseños prefieren 204 ("la operación se ejecutó, el recurso no existe") para que DELETE sea idempotente: llamar DELETE dos veces sobre el mismo id produce el mismo resultado (recurso inexistente). Aquí mantenemos 404 para ser explícitos con el cliente.

Implementación

Elimina la tarea. Retorna confirmación. Si no existe, 404.

@app.delete("/tasks/{task_id}")
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}

Prueba en /docs

  1. Localiza DELETE /tasks/{task_id} y haz clic en "Try it out".
  2. Usa task_id: 4 (o el id de la tarea que creaste antes).
  3. "Execute". Respuesta esperada 200:
    {
      "message": "Task deleted",
      "id": 4
    }
  4. Verificación: ejecuta GET /tasks/4. Deberías recibir 404 confirmando que la tarea ya no existe.

Código completo — app/main.py

from datetime import datetime
from typing import Literal, Optional

from fastapi import FastAPI, HTTPException
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}


# --- Base de datos en memoria ---

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)


# --- App FastAPI ---

app = FastAPI(
    title="To-Do List API",
    description="API CRUD de tareas. Módulo 6 — FastAPI Fundamentals.",
    version="1.0.0",
)


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


@app.get("/tasks", response_model=list[TaskResponse])
def list_tasks():
    return tasks_db


@app.get("/tasks/{task_id}", response_model=TaskResponse)
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)
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)
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)
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}")
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}

Resumen de status codes

CódigoSignificadoCuándo se usa
200OKGET, PUT, PATCH, DELETE exitosos; el recurso se devuelve o se confirma la operación
201CreatedPOST exitoso; el recurso fue creado y se devuelve en el body
204No ContentAlternativa para DELETE; éxito sin body (opcional)
404Not FoundEl recurso solicitado no existe (ej. task_id inexistente)
422Unprocessable EntityEl body no cumple el esquema (title vacío, status inválido, etc.)

Usar los códigos correctos mejora la interoperabilidad: los clientes pueden distinguir una creación (201) de una actualización (200), o un recurso inexistente (404) de datos inválidos (422).


Flujo completo de ejemplo

Sigue este flujo en /docs para validar todo el CRUD de principio a fin:

  1. GET /tasks — Observa las 3 tareas iniciales (ids 1, 2, 3).
  2. POST /tasks con {"title": "Mi nueva tarea"} — Recibes 201 con id 4.
  3. GET /tasks/4 — Confirma que la tarea se creó con los defaults (status: "pending", priority: "medium").
  4. PATCH /tasks/4 con {"status": "in_progress"} — Solo status cambia; title y priority siguen iguales.
  5. PUT /tasks/4 con body completo (title, description, status, priority) — Reemplaza todo; verifica que created_at no cambió.
  6. DELETE /tasks/4 — Recibes 200 con mensaje de confirmación.
  7. GET /tasks/4 — 404: la tarea ya no existe.

Si todos los pasos funcionan como se describe, tu implementación CRUD está correcta.


Verificación paso a paso

Comprueba cada punto en orden para validar que tu implementación funciona correctamente:

  1. GET / — Info del servicio. Debes ver un JSON con service, version y total_tasks. Status 200.
  2. GET /tasks — Lista de 3 tareas. Cada tarea tiene id, title, description, status, priority, created_at. Status 200.
  3. GET /tasks/1 — Tarea con id 1. Verifica que todos los campos coincidan con la tarea inicial del seed. Status 200.
  4. GET /tasks/999 — 404 con mensaje "Task 999 not found" en el campo detail. No debe lanzar excepción no capturada.
  5. POST /tasks con body válido — Crea tarea 4, retorna 201. El body de respuesta incluye id y created_at generados por el servidor.
  6. POST /tasks con title vacío {"title":""} — 422. La respuesta indica el campo que falló la validación.
  7. PUT /tasks/4 — Actualiza completa. Envía todos los campos (title, description, status, priority). Verifica que created_at no cambió.
  8. PATCH /tasks/4 con {"status": "completed"} — Solo actualiza status. Los demás campos permanecen iguales.
  9. DELETE /tasks/4 — Elimina y retorna confirmación con message e id. Status 200.
  10. GET /tasks/4 — 404. Confirma que la tarea fue eliminada correctamente.

Flujo completo de ejemplo

Para afianzar los conceptos, sigue este flujo en /docs de principio a fin:

  1. Lista inicial — GET /tasks. Deberías ver 3 tareas con ids 1, 2, 3.
  2. Crear — POST /tasks con {"title": "Mi nueva tarea"}. Recibe 201; la tarea tiene id 4 y created_at generado.
  3. Leer por ID — GET /tasks/4. Verifica que la tarea existe con los datos correctos.
  4. Actualizar parcial — PATCH /tasks/4 con {"status": "in_progress"}. Solo cambia status; title y description siguen iguales.
  5. Actualizar completo — PUT /tasks/4 con un body completo (title, description, status, priority). Verifica que created_at no cambió.
  6. Eliminar — DELETE /tasks/4. Recibes confirmación.
  7. Verificar eliminación — GET /tasks/4. Debe retornar 404.

Este flujo cubre todas las operaciones CRUD y te ayuda a entender la secuencia típica de una API de recursos.


Resumen de status codes

CódigoNombreCuándo usarlo
200OKGET, PUT, PATCH, DELETE exitosos (cuando hay body)
201CreatedPOST exitoso (recurso creado)
204No ContentDELETE exitoso sin body (alternativa a 200)
404Not FoundRecurso inexistente (get by id, put, patch, delete)
422Unprocessable EntityValidación fallida (body inválido)

Referencia rápida: endpoint → acción

EndpointFunciónAcción sobre tasks_db
GET /taskslist_tasksLee toda la lista
GET /tasks/{id}get_taskBusca por id con find_task; 404 si no existe
POST /taskscreate_taskAñade con append; genera id y created_at
PUT /tasks/{id}update_taskReemplaza con tasks_db[idx]=updated; preserva created_at
PATCH /tasks/{id}partial_update_taskexisting.update(update_data) con exclude_unset
DELETE /tasks/{id}delete_tasktasks_db.remove(task)

Detalles importantes

Status 201 para POST

El decorador status_code=201 indica al cliente que el recurso fue creado. Es la convención REST correcta.

Preservar created_at en PUT

En PUT debes mantener created_at del recurso original. No lo sobrescribas con la fecha actual.

exclude_unset en PATCH

model_dump(exclude_unset=True) es fundamental para PATCH. Sin eso, los campos no enviados se serializarían como None y sobrescribirían valores existentes.

response_model

Usar response_model=TaskResponse en GET, POST, PUT, PATCH garantiza que la respuesta siga el esquema definido y que la documentación en /docs sea correcta. FastAPI filtra y valida la salida, por lo que aunque devuelvas un dict, el cliente recibe exactamente los campos definidos en TaskResponse.

200 vs 204 en DELETE

Algunas APIs retornan 204 No Content en DELETE para indicar éxito sin body. Otras retornan 200 con un mensaje de confirmación {"message": "Task deleted"}. Ambas son válidas. 204 es más "puro" desde el punto de vista REST; 200 con body es más informativo para depuración o para que el cliente sepa qué id se eliminó. Aquí usamos 200 por claridad.

tasks_db.index(existing) y la igualdad por referencia

tasks_db.index(existing) busca el índice del elemento existing en la lista. En Python, list.index() usa comparación por igualdad. Como existing proviene de find_task(), que retorna una referencia al mismo dict que está en tasks_db, la búsqueda funciona correctamente. Pero ten cuidado: si en el futuro find_task devolviera una copia del dict en lugar de la referencia original, index(existing) fallaría porque sería un objeto distinto. En nuestra implementación actual, next((t for t in tasks_db if t["id"] == task_id), None) retorna el propio t de la lista, por lo que la referencia es la correcta.

Validación automática (422)

FastAPI valida el body contra TaskCreate o TaskUpdate antes de ejecutar el endpoint. Si el cliente envía {"title": ""} o {"status": "invalid"}, Pydantic rechaza y FastAPI retorna 422 con el detalle de los errores. No necesitas validar a mano.


Errores comunes

  1. PATCH sin exclude_unset — Si usas updates.model_dump() sin exclude_unset=True, los campos no enviados se serializan como None y sobrescriben valores existentes. Ejemplo: {"status": "completed"} con model_dump() normal produciría {"title": None, "description": None, "status": "completed", "priority": None} y borraría title, description y priority.

  2. PUT que sobrescribe created_at — Si haces updated["created_at"] = datetime.utcnow() en PUT, estás cambiando la fecha de creación del recurso. La convención es que created_at sea inmutable; debe preservarse del recurso original.

  3. Olvidar el import de HTTPException — Si usas raise HTTPException(...) sin haber importado HTTPException desde fastapi, obtendrás NameError. Siempre incluye from fastapi import HTTPException (o from fastapi import FastAPI, HTTPException).

  4. Usar return en lugar de raise para erroresreturn {"detail": "Not found"} no establece el status 404 automáticamente. FastAPI devolvería 200. Debes usar raise HTTPException(status_code=404, detail="...") para que el framework envíe la respuesta correcta.

  5. No usar status_code=201 en POST — Si omites status_code=201, FastAPI devolverá 200 por defecto. Funcionalmente puede parecer igual, pero rompe la convención REST y puede confundir a clientes que esperan 201 para recursos creados.


Troubleshooting

  1. "Task X not found" pero la tarea existe — Revisa que task_id coincida con un id en tasks_db. Los ids son enteros; si pasas un string en la URL, FastAPI lo convierte a int automáticamente para path params. Si usas un cliente que envía task_id como query param por error, el endpoint podría estar recibiendo otro valor.

  2. PATCH cambia campos que no envié — Asegúrate de usar model_dump(exclude_unset=True). Sin exclude_unset=True, los campos opcionales con default None se incluyen en el dump y sobrescriben el recurso.

  3. ValueError: list.remove(x): x not in list en DELETE — Ocurre si find_task retorna un dict que no es la misma referencia que está en tasks_db. Por ejemplo, si modificaste find_task para retornar una copia con dict(t) o {**t}, remove() fallaría. find_task debe retornar el elemento original de la lista.

  4. 422 Unprocessable Entity en PUT o POST — El body no cumple el esquema. Revisa que todos los campos requeridos estén presentes, que title no esté vacío, y que status y priority sean valores permitidos ("pending", "in_progress", "completed" y "low", "medium", "high"). La respuesta 422 incluye el detalle de qué campo falló.


Preguntas frecuentes

¿Cuándo usar PUT y cuándo PATCH? — Usa PUT cuando el cliente envía la representación completa del recurso (por ejemplo, un formulario de edición con todos los campos). Usa PATCH cuando solo necesitas modificar uno o pocos campos (por ejemplo, marcar una tarea como completada sin tocar el título ni la descripción).

¿Puedo retornar 204 en DELETE? — Sí. Si quieres que DELETE retorne 204 No Content, cambia el decorador a @app.delete("/tasks/{task_id}", status_code=204) y haz return None en lugar de devolver un diccionario. La convención es válida en ambos casos.

¿Por qué find_task retorna el mismo dict que está en tasks_db? — Porque usamos next((t for t in tasks_db if t["id"] == task_id), None), donde t es directamente el elemento de la lista. No creamos una copia, así que remove(task) y tasks_db.index(existing) funcionan correctamente.

¿Qué pasa si envío campos extra en el body? — Por defecto, Pydantic ignora campos extra si el modelo no los define. Si quieres rechazar cuerpos con campos desconocidos, usa model_config = {"extra": "forbid"} en tu modelo.


Probar con curl (opcional)

Si prefieres probar los endpoints desde la terminal en lugar de /docs, puedes usar curl:

  • GET por ID: curl http://localhost:8000/tasks/1
  • POST crear: curl -X POST http://localhost:8000/tasks -H "Content-Type: application/json" -d '{"title":"Nueva tarea","status":"pending"}'
  • PATCH parcial: curl -X PATCH http://localhost:8000/tasks/4 -H "Content-Type: application/json" -d '{"status":"completed"}'
  • DELETE: curl -X DELETE http://localhost:8000/tasks/4

Asegúrate de que el servidor esté corriendo con uvicorn app.main:app --reload antes de probar. Para PUT necesitarás enviar el body completo; por ejemplo: curl -X PUT http://localhost:8000/tasks/4 -H "Content-Type: application/json" -d '{"title":"Título actualizado","description":"","status":"completed","priority":"high"}'. La respuesta de cada comando aparecerá en la terminal.

Respuesta esperada para GET /tasks/1: JSON con la tarea id 1. Para POST, recibirás 201 y un body con el recurso creado (incluye id y created_at generados). Para PATCH, solo los campos enviados cambian. Para DELETE, un mensaje de confirmación. Si el id no existe en GET, PUT, PATCH o DELETE, recibirás 404.


Consejos para depuración

Cuando algo no funciona como esperas, sigue estos pasos:

  1. Revisa la consola del servidor — FastAPI imprime trazas de errores. Si hay una excepción no capturada, verás el stack trace completo.
  2. Inspecciona la respuesta 422 — El body de una respuesta 422 incluye un array detail con la lista de errores de validación. Cada elemento indica el campo afectado y el motivo (por ejemplo, "field required", "value is not a valid enumeration member").
  3. Verifica el Content-Type — Si usas curl o un cliente REST, asegúrate de enviar Content-Type: application/json en POST, PUT y PATCH. Sin ello, FastAPI puede no parsear el body correctamente.
  4. Compara con el modelo — Si un campo se rechaza, revisa TaskCreate o TaskUpdate: comprueba tipos, longitudes, valores permitidos (Literal) y si el campo es opcional u obligatorio.

Diagrama del flujo CRUD

Cliente                    API FastAPI                    tasks_db
   |                             |                             |
   |-- GET /tasks --------------->|                             |
   |                             |-- list_tasks() ------------->|
   |<-- 200 [tasks] -------------|                             |
   |                             |                             |
   |-- POST /tasks {body} ------->|                             |
   |                             |-- create_task() ----------->| append
   |<-- 201 {task con id} --------|                             |
   |                             |                             |
   |-- GET /tasks/4 ------------->|                             |
   |                             |-- get_task() --------------->| find
   |<-- 200 {task} ---------------|   o 404 si no existe        |
   |                             |                             |
   |-- PATCH /tasks/4 {parcial} ->|                             |
   |                             |-- partial_update_task() ---->| update dict
   |<-- 200 {task actualizada} ---|                             |
   |                             |                             |
   |-- DELETE /tasks/4 ---------->|                             |
   |                             |-- delete_task() ----------->| remove
   |<-- 200 {confirmación} -------|                             |

Este diagrama resume el flujo de datos: el cliente envía peticiones HTTP, FastAPI enruta a la función correspondiente, y esta lee o modifica tasks_db en memoria.

Nota sobre PUT: En el diagrama no se muestra PUT explícitamente, pero su flujo es análogo a PATCH: el cliente envía el body completo, la función update_task reemplaza el recurso en la lista preservando created_at, y retorna 200 con la tarea actualizada.


Orden de parámetros en las funciones

FastAPI resuelve las dependencias según el orden y tipo de los parámetros. En nuestros endpoints, el orden es siempre (task_id: int, ...) para path params y luego el body. Si usas Depends() en el futuro, los parámetros con Depends se resuelven antes de ejecutar la función. No hay un orden estricto entre path params y body, pero es buena práctica poner primero los path params (identifican el recurso) y después el body (los datos a crear o actualizar).


Ejercicio opcional

Antes de avanzar, prueba en /docs:

  1. Crear una tarea con solo title (debe usar defaults para description, status, priority)
  2. Hacer PATCH con body vacío {} — no debe cambiar nada
  3. Hacer PUT sin description — ¿qué pasa? (debería fallar si TaskCreate la requiere, pero la tenemos con default "")

Checklist

  • ✅ GET /tasks/{task_id} con 404 si no existe
  • ✅ POST /tasks con status_code=201
  • ✅ PUT /tasks/{task_id} preservando created_at
  • ✅ PATCH /tasks/{task_id} con exclude_unset=True
  • ✅ DELETE /tasks/{task_id} con confirmación
  • ✅ HTTPException para todos los casos 404
  • ✅ Todas las respuestas usan response_model=TaskResponse donde corresponde
  • ✅ Pruebas en /docs para cada endpoint con casos de éxito y error

Resumen

En esta cápsula implementaste los cinco endpoints CRUD de la API To-Do: GET por ID con manejo 404 mediante raise HTTPException, POST con generación de id y created_at en el servidor y status 201, PUT con reemplazo completo preservando created_at, PATCH con actualización parcial usando exclude_unset=True, y DELETE con confirmación. Aprendiste la diferencia entre PUT y PATCH, por qué raise es preferible a return para errores HTTP, y varios detalles importantes como response_model, la validación automática 422 y los errores típicos al implementar PATCH. En la próxima cápsula añadirás filtros, búsqueda, ordenamiento y paginación a GET /tasks.


Recursos adicionales

  • FastAPI — Path Operations — Parámetros de path, path params como integers, y cómo FastAPI valida automáticamente los tipos y retorna 422 si el path no coincide.
  • HTTP Status Codes — MDN — Referencia completa de códigos 200, 201, 204, 404, 422 con ejemplos y convenciones de uso en APIs REST.
  • RFC 7231 — PUT vs PATCH — Semántica oficial de los métodos HTTP: PUT para reemplazo completo, PATCH para modificaciones parciales. Útil para entender el estándar.
  • Pydantic — model_dump — Documentación de exclude_unset, exclude_none, exclude_defaults y cuándo usar cada uno en actualizaciones parciales.
  • REST API Tutorial — Principios REST, convenciones de nombres, idempotencia, y buenas prácticas para diseñar APIs coherentes y predecibles.

Orden de path params: por qué task_id va después de tasks

En la ruta /tasks/{task_id}, el segmento {task_id} es un path parameter. FastAPI lo inyecta en la función como argumento. Si tuvieras otra ruta como /tasks/completed, podrías tener conflicto: FastAPI interpretaría "completed" como task_id (aunque sea inválido como integer, daría 422). Por eso conviene definir rutas más específicas antes que las genéricas. En nuestro caso, GET /tasks y GET /tasks/{task_id} no colisionan porque la primera no lleva segmento. Si añadieras GET /tasks/recent, deberías ponerla antes de GET /tasks/{task_id} para que FastAPI la matchee correctamente.


Próximo paso

En la Cápsula 04 añadirás filtros (por status, priority), búsqueda por texto en el título, ordenamiento (por fecha, prioridad) y paginación (skip, limit) al endpoint GET /tasks.


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