11. Segundo proyecto práctico (Python + FastAPI)

Objetivo del tema

Consolidar el flujo de trabajo con Claude Code en un ecosistema diferente: construiremos una API REST de tareas con FastAPI, modelos de Pydantic, documentación OpenAPI automática y pruebas con pytest. El foco no estará sólo en obtener código que funcione, sino en dar al agente contratos verificables, aislar responsabilidades y revisar su trabajo con criterio.

11.1 El proyecto y sus criterios de aceptación

La primera versión de la API guardará los datos en memoria. Esto permite concentrarnos en el contrato HTTP y en el trabajo con el agente; una base de datos será una mejora posterior. Al terminar tendremos:

Crear tareas

POST /tasks validará el cuerpo, generará un UUID y responderá 201 Created.

Consultar tareas

GET /tasks listará la colección y GET /tasks/{id} recuperará una tarea.

Errores previsibles

Un identificador inexistente producirá 404; una entrada inválida, 422.

Calidad verificable

pytest cubrirá los caminos felices, la validación, los errores y el aislamiento entre tests.

Contrato visible

FastAPI publicará Swagger UI en /docs, ReDoc en /redoc y el esquema en /openapi.json.

11.2 Preparar Python y las dependencias

Usaremos uv, un administrador moderno de proyectos Python que crea el entorno virtual, resuelve dependencias y mantiene un archivo de bloqueo reproducible. Con Python 3.11 o superior y uv instalado:

mkdir api-tareas
cd api-tareas
git init

uv init --bare
uv add "fastapi[standard]"
uv add --dev pytest httpx

mkdir app
mkdir tests

fastapi[standard] incorpora el servidor y el comando fastapi; httpx es requerido por TestClient. Los archivos pyproject.toml y uv.lock deben versionarse. La carpeta .venv, en cambio, debe quedar ignorada por Git.

Crea también un archivo .gitignore con los artefactos locales más habituales:

.venv/
__pycache__/
.pytest_cache/
*.py[cod]

Si prefieres las herramientas tradicionales, el equivalente es:

# Linux y macOS
python3 -m venv .venv
source .venv/bin/activate

# Windows PowerShell (en lugar de las dos líneas anteriores)
py -m venv .venv
.venv\Scripts\Activate.ps1

python -m pip install "fastapi[standard]" pytest httpx

Una sola estrategia por proyecto: no mezcles comandos de uv y pip durante el ejercicio. En los ejemplos siguientes emplearemos uv run, que ejecuta cada comando dentro del entorno sincronizado sin necesidad de activarlo.

11.3 Estado inicial limpio e instrucciones del proyecto

Crea los paquetes vacíos y un primer commit antes de delegar cambios. En PowerShell puedes usar New-Item app/__init__.py, tests/__init__.py -ItemType File; en Bash, touch app/__init__.py tests/__init__.py. Luego:

git add -A
git commit -m "chore: inicializa proyecto FastAPI"
claude

Dentro de Claude Code ejecuta /init. Revisa el CLAUDE.md generado y complétalo con reglas concretas. Un buen punto de partida:

# API de tareas

## Comandos de verificación
- Tests: `uv run pytest -q`
- Servidor local: `uv run fastapi dev app/main.py`

## Convenciones
- Requiere Python 3.11 o superior y usa anotaciones modernas (`str | None`).
- Define los contratos HTTP con modelos Pydantic; no devuelvas diccionarios sin tipo.
- Separa modelos, repositorio y aplicación.
- Cada cambio de comportamiento debe incluir o actualizar tests.
- No agregues dependencias sin explicar por qué son necesarias.

## Alcance de esta iteración
- Persistencia exclusivamente en memoria. No incorporar una base de datos todavía.

La última regla es tan importante como las demás: evita que el agente sobrediseñe la solución agregando SQLAlchemy, migraciones o contenedores antes de que sean necesarios. Guarda también CLAUDE.md en Git.

11.4 Planificar antes de editar

Activa el modo Plan con Shift+Tab y entrega un contrato completo. Puedes copiar este prompt:

Construye la primera versión de una API de tareas con FastAPI. Necesito POST /tasks (201), GET /tasks (200) y GET /tasks/{task_id} (200 o 404). Cada tarea tiene id UUID, title entre 3 y 100 caracteres, description opcional de hasta 500, status con valor inicial "pending" y created_at en UTC. Separa los modelos Pydantic, un repositorio en memoria y la creación de la aplicación. Usa una factoría create_app() para que cada test obtenga un repositorio vacío. Antes de implementar, inspecciona el proyecto y presenta un plan por archivos. Luego escribe pruebas con TestClient para creación, listado, consulta, 404 y validación 422. Ejecuta uv run pytest -q antes de terminar. No agregues base de datos ni dependencias nuevas.

El prompt especifica cinco dimensiones que hacen comprobable la tarea: rutas, códigos HTTP, esquema de datos, arquitectura y comando de verificación. Si el plan omite una de ellas, corrígelo antes de autorizar ediciones.

Preguntas para revisar el plan del agente
Pregunta Decisión esperada Riesgo que evita
¿Entrada y salida usan el mismo modelo? No. TaskCreate acepta datos del cliente y Task incorpora campos generados por el servidor. Que el cliente pueda elegir id, estado o fecha.
¿Dónde vive el estado? En una instancia de repositorio creada por create_app(). Tests que dependen del orden de ejecución.
¿Quién valida? Pydantic valida el cuerpo y FastAPI convierte parámetros de ruta. Validaciones duplicadas e inconsistentes.
¿Cómo se expresa un ausente? Con HTTPException(status_code=404). Responder 200 con null o errores internos.

11.5 Estructura que debería producir el agente

Una vez aprobado el plan y habilitadas las ediciones, el resultado esperado es pequeño pero modular:

api-tareas/
|-- app/
|   |-- __init__.py
|   |-- main.py          # factoría, rutas y respuestas HTTP
|   |-- models.py        # contratos Pydantic
|   `-- repository.py    # almacenamiento en memoria
|-- tests/
|   |-- __init__.py
|   `-- test_tasks.py
|-- .gitignore
|-- CLAUDE.md
|-- pyproject.toml
`-- uv.lock

No hace falta imponer esta estructura de memoria al agente palabra por palabra: el objetivo del modo Plan es que explore y justifique su propuesta. Sí conviene rechazar capas vacías, patrones empresariales sin necesidad o un único archivo que mezcle almacenamiento, validación y transporte.

11.6 Modelar el contrato con Pydantic

El archivo app/models.py expresa las reglas del dominio en tipos. Un resultado correcto puede verse así:

from datetime import datetime
from enum import Enum
from uuid import UUID

from pydantic import BaseModel, Field


class TaskStatus(str, Enum):
    PENDING = "pending"
    DONE = "done"


class TaskCreate(BaseModel):
    title: str = Field(min_length=3, max_length=100)
    description: str | None = Field(default=None, max_length=500)


class Task(BaseModel):
    id: UUID
    title: str
    description: str | None
    status: TaskStatus
    created_at: datetime

La separación impide que el consumidor envíe campos que pertenecen al servidor. Además, FastAPI reutiliza estos tipos para validar JSON, serializar UUID y fechas, generar el esquema OpenAPI y enriquecer la interfaz de documentación. Es decir: las anotaciones no son comentarios; forman parte del comportamiento observable de la API.

11.7 Repositorio y factoría de aplicación

El repositorio encapsula la colección en memoria. El agente puede elegir una implementación equivalente, pero debería conservar una interfaz pequeña:

# app/repository.py
from datetime import datetime, timezone
from uuid import UUID, uuid4

from .models import Task, TaskCreate, TaskStatus


class TaskRepository:
    def __init__(self) -> None:
        self._tasks: dict[UUID, Task] = {}

    def create(self, data: TaskCreate) -> Task:
        task = Task(
            id=uuid4(),
            title=data.title,
            description=data.description,
            status=TaskStatus.PENDING,
            created_at=datetime.now(timezone.utc),
        )
        self._tasks[task.id] = task
        return task

    def list(self) -> list[Task]:
        return list(self._tasks.values())

    def get(self, task_id: UUID) -> Task | None:
        return self._tasks.get(task_id)

La aplicación convierte las operaciones del repositorio en un contrato HTTP:

# app/main.py
from uuid import UUID

from fastapi import FastAPI, HTTPException, status

from .models import Task, TaskCreate
from .repository import TaskRepository


def create_app() -> FastAPI:
    app = FastAPI(title="API de tareas", version="1.0.0")
    repository = TaskRepository()

    @app.post("/tasks", response_model=Task,
              status_code=status.HTTP_201_CREATED)
    def create_task(data: TaskCreate) -> Task:
        return repository.create(data)

    @app.get("/tasks", response_model=list[Task])
    def list_tasks() -> list[Task]:
        return repository.list()

    @app.get("/tasks/{task_id}", response_model=Task)
    def get_task(task_id: UUID) -> Task:
        task = repository.get(task_id)
        if task is None:
            raise HTTPException(status_code=404, detail="Task not found")
        return task

    return app


app = create_app()

El objeto global app permite iniciar el servidor, mientras que la factoría ofrece una instancia nueva a cada prueba. Esa pequeña decisión elimina gran parte de la fragilidad habitual de los tests con almacenamiento en memoria.

11.8 Probar el comportamiento, no la implementación

FastAPI ofrece TestClient, basado en HTTPX. Las pruebas son funciones def normales: para estos casos no se necesita async ni await. Una base representativa para tests/test_tasks.py:

import pytest
from fastapi.testclient import TestClient

from app.main import create_app


@pytest.fixture
def client() -> TestClient:
    return TestClient(create_app())


def test_create_and_get_task(client: TestClient) -> None:
    response = client.post(
        "/tasks",
        json={"title": "Aprender FastAPI", "description": "Tema 11"},
    )

    assert response.status_code == 201
    created = response.json()
    assert created["title"] == "Aprender FastAPI"
    assert created["status"] == "pending"
    assert created["id"]
    assert created["created_at"]

    get_response = client.get(f"/tasks/{created['id']}")
    assert get_response.status_code == 200
    assert get_response.json() == created


def test_list_starts_empty(client: TestClient) -> None:
    response = client.get("/tasks")
    assert response.status_code == 200
    assert response.json() == []


def test_missing_task_returns_404(client: TestClient) -> None:
    response = client.get("/tasks/123e4567-e89b-12d3-a456-426614174000")
    assert response.status_code == 404
    assert response.json() == {"detail": "Task not found"}


@pytest.mark.parametrize("title", ["", "ab", "x" * 101])
def test_rejects_invalid_title(client: TestClient, title: str) -> None:
    response = client.post("/tasks", json={"title": title})
    assert response.status_code == 422

Estas pruebas atraviesan la frontera HTTP completa: serialización, validación, ruta, repositorio y respuesta. Evitan acoplarse a detalles privados como el nombre del diccionario interno. El fixture crea una aplicación por test, por eso test_list_starts_empty pasa aunque se ejecute después de crear una tarea.

Prueba que puede dar falsa confianza: comprobar únicamente response.status_code == 200. Un test útil también valida la estructura, los valores importantes y al menos un camino de error. Pide al agente una matriz de casos antes de solicitar “más cobertura”; el porcentaje por sí solo no demuestra que se verificó el contrato.

11.9 Ejecutar, inspeccionar y corregir

Solicita al agente que ejecute la suite. También puedes hacerlo desde otra terminal:

uv run pytest -q
uv run fastapi dev app/main.py

Con el servidor activo, abre http://127.0.0.1:8000/docs. Desde Swagger UI despliega POST /tasks, pulsa Try it out y envía:

{
  "title": "Revisar el diff",
  "description": "Confirmar que el agente respetó el plan"
}

Comprueba también un error real enviando un título de dos caracteres. La respuesta 422 debería explicar qué campo falló y qué restricción no se cumplió. Esta inspección manual no reemplaza pytest: confirma que la documentación generada representa correctamente la experiencia de quien consumirá la API.

Si una prueba falla, evita pedir simplemente “arréglalo”. Un prompt de diagnóstico conserva mejor el control:

Analiza la salida del test fallido. Explica en tres puntos la causa raíz, qué contrato se incumple y cuál es el cambio mínimo. No edites todavía. Cuando confirme el diagnóstico, aplica el cambio y ejecuta primero ese test; si pasa, ejecuta la suite completa.

11.10 Segunda iteración: completar una tarea

Ahora ampliamos el comportamiento sin rehacer la arquitectura. Inicia la iteración con el repositorio limpio y pide tests primero:

Agrega PATCH /tasks/{task_id} para cambiar únicamente status entre "pending" y "done". Define un modelo TaskUpdate; no reutilices TaskCreate. Debe devolver la tarea actualizada, responder 404 si no existe y 422 ante un estado inválido. Escribe primero los tres tests que demuestren esos comportamientos, confirma que al menos el nuevo caso feliz falla por la razón esperada y después implementa el cambio mínimo. Ejecuta uv run pytest -q al finalizar y resume los archivos modificados.

Esta secuencia introduce una forma simple de TDD asistido: el test nuevo debe fallar antes de implementar y pasar después. No es necesario observar cada pulsación del agente, pero sí exigir evidencias de ambos estados. Un modelo adecuado sería:

class TaskUpdate(BaseModel):
    status: TaskStatus

En Pydantic, el enum restringe automáticamente los valores aceptados y hace visible esa enumeración en OpenAPI. El repositorio deberá concentrar la mutación; la ruta sólo traduce la petición y el caso ausente a semántica HTTP.

11.11 Revisión final y commit

Lista de control antes de aceptar el trabajo
Control Comando o acción Resultado esperado
Estado de la suite uv run pytest -q Todos los casos pasan sin warnings propios del proyecto.
Contrato OpenAPI Abrir /docs y /openapi.json Rutas, modelos, enums y códigos documentados.
Cambios realizados git status --short y git diff Sólo archivos acordados; sin secretos, cachés ni .venv.
Dependencias Revisar pyproject.toml y uv.lock Ninguna incorporación injustificada.
Aislamiento Ejecutar tests en distinto orden o individualmente. Ninguno depende del estado dejado por otro.
Recuperación Doble Esc / /rewind si la iteración se desvió Regresar al checkpoint, precisar el prompt y reintentar.

Cuando el diff y las pruebas estén en orden, crea una unidad de cambio clara:

git add app tests pyproject.toml uv.lock CLAUDE.md .gitignore
git commit -m "feat: agrega API de tareas con FastAPI y pytest"

11.12 Desafíos para continuar

Trabaja cada mejora en una sesión o checkpoint independiente. Primero pide un plan y criterios de aceptación; luego implementa:

  • Agregar DELETE /tasks/{id} con respuesta 204 No Content y test que confirme la eliminación.
  • Filtrar GET /tasks?status=done mediante un parámetro de consulta tipado.
  • Ordenar el listado por created_at y documentar el criterio cuando dos valores coincidan.
  • Reemplazar el repositorio en memoria por SQLite, conservando intactos los tests del contrato HTTP.
  • Configurar Ruff para formato y lint, incorporando sus comandos a CLAUDE.md.
  • Usar claude -p para generar un README a partir del esquema /openapi.json y revisar el resultado antes de guardarlo.

Principio transferible: Express y FastAPI difieren en lenguaje, tipado y herramientas, pero el flujo profesional con Claude Code es el mismo: estado inicial limpio, instrucciones persistentes, contrato observable, plan revisado, cambios pequeños, pruebas ejecutadas y diff inspeccionado.

Conclusión: en este segundo proyecto pasaste de un prompt general a una especificación verificable, aprovechaste el tipado de Python y Pydantic como parte del contrato, aislaste el almacenamiento mediante una factoría y exigiste pruebas de comportamiento. El resultado no es sólo una API funcional: es una base que puede evolucionar sin perder control sobre lo que modifica el agente. En el próximo tema integraremos Claude Code con editores e IDEs para combinar este flujo con la navegación visual del código.