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étodo | Ruta | Descripción | Status codes |
|---|---|---|---|
| GET | /tasks | Lista todas (ya implementado) | 200 |
| GET | /tasks/{task_id} | Obtener una tarea | 200, 404 |
| POST | /tasks | Crear tarea | 201, 422 |
| PUT | /tasks/{task_id} | Actualizar completa | 200, 404, 422 |
| PATCH | /tasks/{task_id} | Actualizar parcial | 200, 404, 422 |
| DELETE | /tasks/{task_id} | Eliminar | 200, 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
- Abre
http://localhost:8000/docs, localizaGET /tasks/{task_id}y haz clic en "Try it out". - 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" } - 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
- Localiza
POST /tasksen /docs y haz clic en "Try it out". - Usa este body de ejemplo (o solo
titlesi quieres probar los defaults):{ "title": "Revisar documentación de la API", "description": "Validar que /docs muestre todos los endpoints", "status": "pending", "priority": "medium" } - "Execute". Respuesta esperada 201 con el recurso creado:
Observa que{ "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" }idycreated_atvienen 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
- Localiza
PUT /tasks/{task_id}y haz clic en "Try it out". - Usa
task_id: 4(o el id de una tarea que hayas creado con POST). - 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" } - "Execute". Deberías recibir 200 con el recurso actualizado. Verifica que
created_atno 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
- Localiza
PATCH /tasks/{task_id}y haz clic en "Try it out". - Usa
task_id: 4y un body parcial:{ "status": "completed" } - "Execute". 200 con el recurso actualizado. Verifica que solo
statuscambió;title,descriptionypriorityse mantienen. - Prueba también con body vacío
{}: no debe cambiar nada.
PUT vs PATCH: Comparación directa
| Aspecto | PUT | PATCH |
|---|---|---|
| Semántica | Reemplazo completo del recurso | Actualización parcial (solo campos enviados) |
| Body | Debe incluir todos los campos requeridos (TaskCreate) | Solo los campos que quieres cambiar (TaskUpdate, todos opcionales) |
| Campos no enviados | N/A: el cliente envía todo | Se ignoran; el recurso conserva sus valores actuales |
| created_at | Debe preservarse del recurso original | No aplica: normalmente no se toca |
| Caso de uso | Cliente tiene la representación completa y quiere reemplazarla | Cliente quiere cambiar uno o pocos campos (ej. marcar como completada) |
| Implementación | task.model_dump() + preservar created_at + reemplazar en la lista | updates.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
- Localiza
DELETE /tasks/{task_id}y haz clic en "Try it out". - Usa
task_id: 4(o el id de la tarea que creaste antes). - "Execute". Respuesta esperada 200:
{ "message": "Task deleted", "id": 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ódigo | Significado | Cuándo se usa |
|---|---|---|
| 200 | OK | GET, PUT, PATCH, DELETE exitosos; el recurso se devuelve o se confirma la operación |
| 201 | Created | POST exitoso; el recurso fue creado y se devuelve en el body |
| 204 | No Content | Alternativa para DELETE; éxito sin body (opcional) |
| 404 | Not Found | El recurso solicitado no existe (ej. task_id inexistente) |
| 422 | Unprocessable Entity | El 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:
- GET /tasks — Observa las 3 tareas iniciales (ids 1, 2, 3).
- POST /tasks con
{"title": "Mi nueva tarea"}— Recibes 201 con id 4. - GET /tasks/4 — Confirma que la tarea se creó con los defaults (status: "pending", priority: "medium").
- PATCH /tasks/4 con
{"status": "in_progress"}— Solo status cambia; title y priority siguen iguales. - PUT /tasks/4 con body completo (title, description, status, priority) — Reemplaza todo; verifica que created_at no cambió.
- DELETE /tasks/4 — Recibes 200 con mensaje de confirmación.
- 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:
- GET / — Info del servicio. Debes ver un JSON con
service,versionytotal_tasks. Status 200. - GET /tasks — Lista de 3 tareas. Cada tarea tiene
id,title,description,status,priority,created_at. Status 200. - GET /tasks/1 — Tarea con id 1. Verifica que todos los campos coincidan con la tarea inicial del seed. Status 200.
- GET /tasks/999 — 404 con mensaje
"Task 999 not found"en el campodetail. No debe lanzar excepción no capturada. - POST /tasks con body válido — Crea tarea 4, retorna 201. El body de respuesta incluye
idycreated_atgenerados por el servidor. - POST /tasks con title vacío
{"title":""}— 422. La respuesta indica el campo que falló la validación. - PUT /tasks/4 — Actualiza completa. Envía todos los campos (title, description, status, priority). Verifica que
created_atno cambió. - PATCH /tasks/4 con
{"status": "completed"}— Solo actualiza status. Los demás campos permanecen iguales. - DELETE /tasks/4 — Elimina y retorna confirmación con
messageeid. Status 200. - 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:
- Lista inicial — GET /tasks. Deberías ver 3 tareas con ids 1, 2, 3.
- Crear — POST /tasks con
{"title": "Mi nueva tarea"}. Recibe 201; la tarea tiene id 4 y created_at generado. - Leer por ID — GET /tasks/4. Verifica que la tarea existe con los datos correctos.
- Actualizar parcial — PATCH /tasks/4 con
{"status": "in_progress"}. Solo cambia status; title y description siguen iguales. - Actualizar completo — PUT /tasks/4 con un body completo (title, description, status, priority). Verifica que created_at no cambió.
- Eliminar — DELETE /tasks/4. Recibes confirmación.
- 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ódigo | Nombre | Cuándo usarlo |
|---|---|---|
| 200 | OK | GET, PUT, PATCH, DELETE exitosos (cuando hay body) |
| 201 | Created | POST exitoso (recurso creado) |
| 204 | No Content | DELETE exitoso sin body (alternativa a 200) |
| 404 | Not Found | Recurso inexistente (get by id, put, patch, delete) |
| 422 | Unprocessable Entity | Validación fallida (body inválido) |
Referencia rápida: endpoint → acción
| Endpoint | Función | Acción sobre tasks_db |
|---|---|---|
| GET /tasks | list_tasks | Lee toda la lista |
| GET /tasks/{id} | get_task | Busca por id con find_task; 404 si no existe |
| POST /tasks | create_task | Añade con append; genera id y created_at |
| PUT /tasks/{id} | update_task | Reemplaza con tasks_db[idx]=updated; preserva created_at |
| PATCH /tasks/{id} | partial_update_task | existing.update(update_data) con exclude_unset |
| DELETE /tasks/{id} | delete_task | tasks_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
-
PATCH sin exclude_unset — Si usas
updates.model_dump()sinexclude_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. -
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. -
Olvidar el import de HTTPException — Si usas
raise HTTPException(...)sin haber importadoHTTPExceptiondesdefastapi, obtendrásNameError. Siempre incluyefrom fastapi import HTTPException(ofrom fastapi import FastAPI, HTTPException). -
Usar return en lugar de raise para errores —
return {"detail": "Not found"}no establece el status 404 automáticamente. FastAPI devolvería 200. Debes usarraise HTTPException(status_code=404, detail="...")para que el framework envíe la respuesta correcta. -
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
-
"Task X not found" pero la tarea existe — Revisa que
task_idcoincida con unidentasks_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. -
PATCH cambia campos que no envié — Asegúrate de usar
model_dump(exclude_unset=True). Sinexclude_unset=True, los campos opcionales con default None se incluyen en el dump y sobrescriben el recurso. -
ValueError: list.remove(x): x not in list en DELETE — Ocurre si
find_taskretorna un dict que no es la misma referencia que está entasks_db. Por ejemplo, si modificastefind_taskpara retornar una copia condict(t)o{**t},remove()fallaría.find_taskdebe retornar el elemento original de la lista. -
422 Unprocessable Entity en PUT o POST — El body no cumple el esquema. Revisa que todos los campos requeridos estén presentes, que
titleno esté vacío, y questatusyprioritysean 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:
- Revisa la consola del servidor — FastAPI imprime trazas de errores. Si hay una excepción no capturada, verás el stack trace completo.
- Inspecciona la respuesta 422 — El body de una respuesta 422 incluye un array
detailcon 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"). - Verifica el Content-Type — Si usas curl o un cliente REST, asegúrate de enviar
Content-Type: application/jsonen POST, PUT y PATCH. Sin ello, FastAPI puede no parsear el body correctamente. - 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:
- Crear una tarea con solo
title(debe usar defaults para description, status, priority) - Hacer PATCH con body vacío
{}— no debe cambiar nada - 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_defaultsy 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