Module 6: Project — CRUD API (To-Do List)
Paso 1: Setup y Modelos
Descripción
En este paso configuras el proyecto, defines los modelos Pydantic para Task (TaskCreate, TaskUpdate, TaskResponse), creas la base de datos en memoria con datos de ejemplo, y las funciones auxiliares (next_id, find_task). Es la base sobre la que construirás los endpoints en la siguiente cápsula.
Esta cápsula sienta los cimientos de todo el proyecto. Sin una estructura clara, modelos bien validados y datos de prueba, los endpoints posteriores serían frágiles o inconsistentes. Cada decisión aquí — desde el tipo de los campos hasta el formato del almacenamiento — repercute en el resto del módulo. No te saltes pasos: la inversión en setup correcto ahorra debugging después.
El enfoque es paso a paso y autocontenido. Todo el código vive en main.py por ahora para que no te distraigas con múltiples archivos. En módulos avanzados podrás separar routers, schemas y servicios; aquí la prioridad es ver cómo encajan las piezas.
Antes de empezar
¿Qué vas a construir?
Una To-Do List API con operaciones CRUD: crear, leer, actualizar y eliminar tareas. Cada tarea tiene título, descripción, estado (pending, in_progress, completed), prioridad (low, medium, high), id único y fecha de creación. Los endpoints vendrán en la Cápsula 03; aquí preparas los modelos y el almacenamiento.
¿Qué introdujo la Cápsula 01?
La Cápsula 01 (introducción del módulo) te mostró el mapa completo del proyecto: las seis cápsulas, el orden recomendado y cómo cada una conecta con los módulos anteriores (M1–M5). Si no la leíste, conviene echarle un ojo para entender el contexto.
Verifica tu setup
Antes de crear el proyecto, confirma que tienes Python 3.10+ y pip disponibles. En la terminal:
python --version # o python3 --version
pip --version
Si usas un entorno virtual para otros proyectos, puedes crear uno nuevo para este módulo (recomendado):
mkdir todo-api && cd todo-api
python -m venv venv
source venv/bin/activate # En Windows: venv\Scripts\activate
Con eso listo, empieza a crear la estructura.
Orden recomendado de trabajo
- Crea la carpeta
todo-apiy el venv - Crea
app/con__init__.py(vacío) ymain.py - Añade
requirements.txty.gitignore - Define los modelos Pydantic (copiando el código completo más abajo o escribiendo campo por campo)
- Añade
_parse_dt,tasks_db,next_idyfind_task - Crea la app FastAPI con los dos endpoints
- Prueba con
uvicorn app.main:app --reload
Objetivos de esta cápsula
- 🔧 Crear la estructura del proyecto To-Do List API
- 📦 Definir modelos Pydantic con validación apropiada
- 💾 Implementar almacenamiento en memoria con tareas de ejemplo
- 🛠️ Proveer utilidades para generar IDs y buscar tareas
Estructura del proyecto
todo-api/
├── venv/
├── app/
│ ├── __init__.py
│ └── main.py ← Todo el código por ahora
├── requirements.txt
└── .gitignore
requirements.txt
fastapi>=0.115.0
uvicorn[standard]>=0.32.0
.gitignore
venv/
__pycache__/
*.pyc
.env
Importante: el archivo app/__init__.py puede estar vacío, pero debe existir. Sin él, Python no reconoce app como paquete y uvicorn app.main:app fallará. Crea el archivo aunque no le pongas nada dentro.
Modelos Pydantic
¿Por qué tres modelos distintos?
En APIs REST bien diseñadas, lo que el cliente envía (request) suele ser diferente de lo que el servidor devuelve (response). Aquí separamos en tres modelos:
- TaskCreate: lo que el cliente envía al crear (POST). No incluye
idnicreated_atporque el servidor los genera. - TaskUpdate: lo que el cliente envía al actualizar (PATCH). Todos los campos son opcionales para permitir actualizaciones parciales.
- TaskResponse: lo que el servidor devuelve en todas las operaciones de lectura. Incluye
idycreated_at.
Si usaras un solo modelo para todo, tendrías que permitir id y created_at en el request (el cliente podría falsificarlos) o hacer campos opcionales que complicarían la validación. La separación evita esos problemas.
Cuándo usar cada modelo en los endpoints (referencia para la Cápsula 03):
| Operación | Request body | Response |
|---|---|---|
| POST (crear) | TaskCreate | TaskResponse |
| PUT (reemplazar) | TaskCreate | TaskResponse |
| PATCH (actualizar parcial) | TaskUpdate | TaskResponse |
| GET por id, GET lista | — | TaskResponse |
Así garantizas que el cliente nunca envíe id o created_at, y que el servidor siempre devuelva la estructura completa.
TaskCreate — Crear tarea (POST)
Campos que el cliente envía al crear:
| Campo | Tipo | Constraints | Default |
|---|---|---|---|
| title | str | min_length=1, max_length=200 | — |
| description | str | max_length=1000, opcional | "" |
| status | str | Literal["pending", "in_progress", "completed"] | "pending" |
| priority | str | Literal["low", "medium", "high"] | "medium" |
Por qué cada constraint
- title con
min_length=1: evita tareas sin título. Si permitieras string vacío (""), la API aceptaría{"title": ""}y tendrías tareas inútiles. Sinmin_length,Field(max_length=200)solo validaría el máximo. - title con
max_length=200: límite razonable para un título visible en listas. Sin límite, un cliente podría enviar strings gigantes y saturar memoria o bases de datos. - description con
default=""ymax_length=1000: la descripción es opcional; si no la envías, se usa cadena vacía. El max_length evita payloads excesivos. - status y priority con
Literal: restringe los valores a una lista cerrada. Cualquier otro valor (por ejemplo"urgent"o"done") produce un error 422 de validación.
Diferencia entre con y sin constraint en title
Si definieras title: str sin Field(min_length=1):
# Con solo title: str (sin min_length)
{"title": ""} # ✅ Se aceptaría — tarea sin título
{"title": "Hola"} # ✅ Se aceptaría
Con Field(min_length=1):
{"title": ""} # ❌ 422 Unprocessable Entity
{"title": "Hola"} # ✅ Se acepta
Ejemplo de error 422 cuando falla la validación
Si el cliente envía:
{
"title": "",
"status": "invalid_status"
}
FastAPI devuelve 422 con un cuerpo similar a:
{
"detail": [
{
"type": "string_too_short",
"loc": ["body", "title"],
"msg": "String should have at least 1 character"
},
{
"type": "literal_error",
"loc": ["body", "status"],
"msg": "Input should be 'pending', 'in_progress' or 'completed'"
}
]
}
Pydantic valida automáticamente y FastAPI convierte los errores en respuestas HTTP estándar.
TaskUpdate — Actualizar tarea (PATCH)
Todos los campos opcionales para actualización parcial:
| Campo | Tipo | Constraints |
|---|---|---|
| title | Optional[str] | min_length=1, max_length=200 |
| description | Optional[str] | max_length=1000 |
| status | Optional[str] | Literal[...] |
| priority | Optional[str] | Literal[...] |
Por qué todos opcionales
En PATCH, el cliente puede enviar solo los campos que quiere cambiar. Por ejemplo, {"status": "completed"} solo actualiza el estado. Si title fuera obligatorio, tendrías que enviar todos los campos en cada actualización, lo que no es PATCH real.
Optional con default=None
Optional[str] = Field(default=None, ...) significa: si el cliente no envía el campo, será None. Si lo envía, debe cumplir las constraints (p. ej. min_length=1 para title). Es crucial usar = None explícitamente; de lo contrario, un campo Optional[str] sin default puede dar errores de validación confusos.
TaskResponse — Respuesta (GET, POST, PUT, PATCH)
Incluye id y created_at generados por el servidor:
| Campo | Tipo | Descripción |
|---|---|---|
| id | int | ID único |
| title | str | Título |
| description | str | Descripción |
| status | str | Estado |
| priority | str | Prioridad |
| created_at | datetime | Fecha de creación |
Por qué from_attributes=True
model_config = {"from_attributes": True} (antes orm_mode en Pydantic v1) permite que Pydantic construya un TaskResponse desde un objeto que tenga atributos con esos nombres — por ejemplo, un diccionario con claves id, title, etc., o un modelo ORM. Sin esto, response_model=TaskResponse fallaría cuando devuelvas tasks_db (lista de dicts), porque Pydantic por defecto espera un dict con claves exactas o un modelo Pydantic. Con from_attributes=True, puede leer las claves del dict como si fueran atributos.
Base de datos en memoria
Usamos una lista de diccionarios. Cada tarea es un dict con las mismas claves que TaskResponse. El formato permite serialización directa a JSON y compatibilidad con Pydantic.
Estructura de cada tarea en la lista
Cada elemento de tasks_db debe tener exactamente estas claves: id, title, description, status, priority, created_at. Si falta alguna, Pydantic fallará al construir TaskResponse. Los tipos deben coincidir: id como int, created_at como datetime, el resto como strings.
La función _parse_dt
def _parse_dt(s: str) -> datetime:
return datetime.fromisoformat(s.replace("Z", "+00:00"))
datetime.fromisoformat() acepta strings como "2025-03-01T10:00:00". El .replace("Z", "+00:00") convierte el sufijo Z (UTC en ISO 8601) a +00:00, que fromisoformat entiende. Sin eso, algunos Python pueden fallar con Z. Así almacenamos datetime reales en el dict en lugar de strings, y Pydantic los serializa correctamente a ISO 8601 en las respuestas.
Por qué 3 tareas de ejemplo
- No 0: con la lista vacía,
GET /tasksdevolvería[], y es más difícil probar la estructura de una tarea y la serialización. - No 10+: con demasiadas, el archivo se vuelve ruidoso sin aportar mucho para este paso.
- 3 permite cubrir los tres estados (
pending,in_progress,completed), distintas prioridades y tareas con y sin descripción. Es suficiente para validar que todo funciona.
Tradeoffs: memoria vs archivo vs base de datos
| Almacenamiento | Ventajas | Desventajas |
|---|---|---|
| En memoria (lista) | Simple, sin dependencias, ideal para aprendizaje | Se pierde al reiniciar, no escala, un solo proceso |
| Archivo JSON | Persistencia simple, legible | Concurrencia complicada, sin consultas, lento con muchos datos |
| Base de datos | Persistencia real, consultas, escalabilidad | Requiere PostgreSQL/SQLite, ORM, migraciones |
Para este módulo, en memoria es la opción correcta: te enfocas en FastAPI y Pydantic sin distraerte con bases de datos.
¿Qué pasa cuando reinicias el servidor?
Al parar uvicorn y volver a ejecutarlo, tasks_db se vuelve a cargar con las 3 tareas iniciales. Cualquier tarea que hayas creado o modificado con POST/PATCH/DELETE (en la siguiente cápsula) se pierde. Es el comportamiento esperado de un almacenamiento en memoria.
Datos de ejemplo para empezar con la API poblada:
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"),
},
]
Estructura esperada de cada dict
Cada elemento de tasks_db debe tener exactamente las mismas claves que TaskResponse: id, title, description, status, priority, created_at. El valor de created_at debe ser un objeto datetime (no un string), por eso usas _parse_dt() al definir los datos iniciales. Si más adelante añades tareas con POST, tendrás que crear dicts con esa misma estructura para que response_model=list[TaskResponse] funcione correctamente.
Funciones auxiliares
next_id()
def next_id() -> int:
return max((t["id"] for t in tasks_db), default=0) + 1
Genera el siguiente ID disponible. La expresión max((t["id"] for t in tasks_db), default=0) obtiene el ID más alto; si tasks_db está vacío, el generador no produce valores y max daría error. El default=0 evita eso: con lista vacía, max devuelve 0, así que next_id() devuelve 1. Es un patrón robusto para IDs autoincrementales.
Alternativa (menos eficiente con listas grandes): max([t["id"] for t in tasks_db], default=0) + 1 crea una lista intermedia; el generador es más eficiente en memoria.
find_task(task_id: int)
def find_task(task_id: int) -> dict | None:
return next((t for t in tasks_db if t["id"] == task_id), None)
Busca una tarea por ID. La expresión (t for t in tasks_db if t["id"] == task_id) es un generador que recorre las tareas hasta encontrar una con ese id. next(..., None) devuelve la primera coincidencia o None si no hay ninguna. Es más eficiente que recorrer toda la lista con un bucle y luego hacer return manual.
Alternativa: un bucle for con return t al encontrar y return None al final. Funciona igual, pero el patrón con next es más idiomático en Python.
Código completo — app/main.py
from datetime import datetime
from typing import Literal, Optional
from fastapi import FastAPI
from pydantic import BaseModel, Field
# --- Modelos Pydantic ---
StatusType = Literal["pending", "in_progress", "completed"]
PriorityType = Literal["low", "medium", "high"]
class TaskCreate(BaseModel):
"""Modelo para crear una tarea."""
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")
model_config = {"json_schema_extra": {"example": {"title": "Mi primera tarea", "priority": "high"}}}
class TaskUpdate(BaseModel):
"""Modelo para actualización parcial (PATCH). Todos los campos opcionales."""
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):
"""Modelo de respuesta para una tarea."""
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:
"""Genera el siguiente ID disponible."""
return max((t["id"] for t in tasks_db), default=0) + 1
def find_task(task_id: int) -> dict | None:
"""Busca una tarea por ID. Retorna None si no existe."""
return next((t for t in tasks_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():
"""Info del servicio."""
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():
"""Lista todas las tareas. Filtros y paginación en la siguiente cápsula."""
return tasks_db
Decisiones de diseño
Literal en vez de Enum para status y priority
Literal["pending", "in_progress", "completed"] es más conciso y suficiente para valores fijos que no cambian. Enum aporta ventajas cuando necesitas nombres alternativos (alias), reutilizar los valores en varios modelos, o añadir métodos. Para este caso, Literal reduce código y la documentación de OpenAPI muestra los valores permitidos igual de bien.
description con default="" en vez de None
Con description: str = "" evitamos tener que manejar None en la lógica y en el frontend. Una descripción vacía y una ausencia de descripción se tratan igual: string vacío. Si usaras Optional[str] = None, tendrías que distinguir entre "no enviado" y "enviado vacío", y la serialización JSON podría incluir "description": null, lo que algunos clientes manejan peor.
from_attributes=True en TaskResponse
Sin from_attributes, Pydantic espera que el objeto sea un dict con las claves exactas o una instancia del mismo modelo. Nuestra tasks_db es una lista de dicts; FastAPI pasa cada dict a Pydantic para construir TaskResponse. Con from_attributes=True, Pydantic puede leer dict["id"] como obj.id, etc., y construir el modelo correctamente. Es la opción estándar para modelos de respuesta que reciben dicts u objetos ORM.
Tres modelos en vez de uno
Un solo modelo que sirva para crear, actualizar y responder obligaría a hacer campos opcionales o a aceptar que el cliente envíe id y created_at. Separar TaskCreate, TaskUpdate y TaskResponse sigue el principio de mínima exposición: cada operación recibe y devuelve exactamente lo que necesita. Es más mantenible y más seguro.
Errores comunes
-
⚠️ Olvidar
from_attributes=Trueen TaskResponse: al devolvertasks_dbconresponse_model=list[TaskResponse], obtienes un error tipo "Expected dict, got dict" o "value is not a valid dict". Pydantic no puede mapear el dict al modelo sin esta opción. -
⚠️
Optional[str]sin= None: si escribestitle: Optional[str]en TaskUpdate sindefault=None, el campo sigue siendo obligatorio. El cliente tendría que enviar"title": nullexplícitamente para no enviarlo. Siempre usaOptional[str] = None(oField(default=None)) para campos opcionales. -
⚠️ Errores de tipeo en valores Literal: escribir
"complited"en vez de"completed"o"hight"en vez de"high"produce 422. Los valores deben coincidir exactamente. Si usas un frontend, define constantes o enums para evitar typos. -
⚠️ Problemas con
_parse_dty zonas horarias: si usas strings conZ(UTC) y tudatetime.fromisoformatno lo soporta bien en tu versión de Python, la conversión falla. El.replace("Z", "+00:00")soluciona esto. Para fechas sin zona horaria ("2025-03-01T10:00:00"), se interpretan como hora local; para producción con múltiples zonas, considera siempre trabajar en UTC. -
⚠️ Olvidar
__init__.pyenapp/: sin ese archivo,appno es un paquete Python yfrom app.main import appouvicorn app.main:apppueden fallar con "No module named 'app'". Asegúrate de queapp/__init__.pyexiste (puede estar vacío).
Verificación
Ejecuta:
uvicorn app.main:app --reload
Desde la carpeta todo-api, con el venv activado y las dependencias instaladas (pip install -r requirements.txt). Deberías ver algo como:
INFO: Uvicorn running on http://127.0.0.1:8000
INFO: Application startup complete.
GET /
En el navegador o con curl: http://127.0.0.1:8000/. Respuesta esperada:
{
"service": "To-Do List API",
"version": "1.0.0",
"total_tasks": 3
}
GET /tasks
http://127.0.0.1:8000/tasks. Debes recibir un array JSON con 3 tareas. Cada una tiene:
id(número)title(string)description(string)status(string: "pending", "in_progress" o "completed")priority(string: "low", "medium" o "high")created_at(string ISO 8601, p. ej."2025-03-01T10:00:00")
Documentación
Abre http://127.0.0.1:8000/docs. En Swagger UI deberías ver los endpoints GET / y GET /tasks. Puedes probarlos desde ahí.
Prueba rápida con curl
Si prefieres la terminal:
curl http://127.0.0.1:8000/
curl http://127.0.0.1:8000/tasks
El primer comando devuelve el JSON con service, version y total_tasks. El segundo devuelve el array de las 3 tareas. Si ambos funcionan, tu setup está correcto.
Troubleshooting
El servidor no arranca — "No module named 'app'"
- Verifica que estás en la carpeta
todo-api(la que contieneapp/). - Comprueba que existe
app/__init__.py. - Si usas un venv, asegúrate de que está activado.
GET /tasks devuelve 500 o un error de validación
- Revisa que cada dict en
tasks_dbtenga las clavesid,title,description,status,priority,created_at. - Verifica que
created_atsea un objetodatetime, no un string (usa_parse_dtal inicializar).
Cambios en el código no se reflejan
- Con
--reload, uvicorn debería recargar al guardar. Si no ocurre, reinicia manualmente (Ctrl+C y vuelve a ejecutar).
Puerto 8000 ya en uso
- Usa otro puerto:
uvicorn app.main:app --reload --port 8001.
Error "ModuleNotFoundError" al importar fastapi o uvicorn
- Asegúrate de haber ejecutado
pip install -r requirements.txtdentro del venv activado. - Si usas varios proyectos, puede que el venv de otro proyecto esté activo; desactívalo y activa el de
todo-api.
Resumen
En esta cápsula has:
- 📂 Creado la estructura del proyecto (
todo-api/,app/,requirements.txt,.gitignore) - 📋 Definido tres modelos Pydantic (TaskCreate, TaskUpdate, TaskResponse) con validación adecuada
- 💾 Configurado el almacenamiento en memoria con 3 tareas de ejemplo y la función
_parse_dt - 🔧 Implementado
next_id()yfind_task()como utilidades - 🚀 Configurado la app FastAPI con
GET /yGET /tasks
En la Cápsula 03 añadirás los endpoints CRUD: GET por id, POST, PUT, PATCH y DELETE. Los modelos y funciones de esta cápsula serán la base de esas operaciones.
Preparación para la siguiente cápsula
Antes de pasar a la Cápsula 03, asegúrate de poder:
- Crear una tarea manualmente en
tasks_db(un dict con id, title, description, status, priority, created_at) y verla enGET /tasks. - Llamar a
find_task(1)yfind_task(999)en un script o consola para ver que devuelve el dict correcto oNone. - Entender por qué
next_id()devuelve 4 cuandotasks_dbtiene 3 tareas (ids 1, 2, 3).
Si alguno de estos puntos no está claro, repasa las funciones auxiliares y la estructura de tasks_db.
Recursos adicionales
Profundiza en los conceptos de esta cápsula con estos enlaces:
- Pydantic V2 — Field constraints: documentación oficial de
min_length,max_lengthy otras restricciones en campos. - FastAPI — Response Model: cómo FastAPI usa
response_modelpara serializar y validar respuestas. - Pydantic — from_attributes (orm_mode): por qué necesitas esta opción para dicts y modelos ORM.
- Python datetime — fromisoformat: referencia del método para parsear fechas ISO 8601.
- HTTP 422 Unprocessable Entity: cuándo y por qué FastAPI devuelve este código de error.
- REST — PATCH vs PUT: diferencia semántica entre actualización parcial y reemplazo completo.
Checklist
- ✅ Proyecto creado con
app/main.py,requirements.txt,.gitignore - ✅ TaskCreate con title, description, status, priority y Field constraints
- ✅ TaskUpdate con todos los campos Optional
- ✅ TaskResponse con id, title, description, status, priority, created_at
- ✅ tasks_db con al menos 3 tareas de ejemplo
- ✅ next_id() y find_task() implementadas
- ✅ GET / y GET /tasks funcionando
Preguntas frecuentes
¿Por qué Literal en vez de Enum?
Literal["pending", "in_progress", "completed"] es más conciso para valores fijos. Enum es mejor cuando los valores se reutilizan en varios modelos o necesitas comportamiento adicional.
¿Puedo usar UUID en vez de int para id?
Sí. Cambia id: int por id: UUID en TaskResponse y usa uuid.uuid4() en next_id(). La lógica de búsqueda y almacenamiento se adapta igual.
¿Por qué created_at en TaskResponse y no en TaskCreate?
El servidor genera created_at al crear. El cliente no debe poder falsificar fechas. Por eso TaskCreate no lo incluye y TaskResponse sí.
Preparación para la siguiente cápsula
Antes de pasar a la Cápsula 03 (CRUD endpoints), conviene que tengas claro:
- TaskCreate y TaskUpdate serán el tipo del parámetro
Body()en POST, PUT y PATCH - TaskResponse será el
response_modelen todos los endpoints que devuelvan una tarea o lista de tareas - find_task(task_id) se usará para verificar si una tarea existe antes de actualizar o eliminar; si retorna
None, devolverás 404 - next_id() se usará en POST para asignar el ID a la nueva tarea
El código de esta cápsula es la base exacta que necesitas. No cambies la estructura de los dicts en tasks_db ni las firmas de las funciones; la siguiente cápsula los usará tal cual.
Próximo paso
En la Cápsula 03 implementarás todos los endpoints CRUD: GET by id, POST, PUT, PATCH, DELETE. Usarás find_task para verificar existencia y next_id para crear nuevas tareas. Los modelos que definiste aquí serán los parámetros y response_model de cada endpoint.
Módulo 6, Cápsula 02 — FastAPI Fundamentals Guide