Cuaderno/Proyectos/4 · API del inventario con FastAPI
Proyecto 4 de 10 · Bloque 2

API del inventario con FastAPI

El mismo inventario del Proyecto 3, ahora accesible por HTTP para otros programas. No escribes lógica nueva: escribes una puerta de entrada nueva. El concepto es el contrato: request → response, códigos de estado, y un servidor que no recuerda nada entre llamadas.

concepto · HTTP y el contrato cliente–servidor5 sesionesfastapi · pydantic · uvicorn · testclient · compose
Construyessrc/inventario/api/ sobre el repo inventario-lab
AprendesMétodos, códigos HTTP, validación en el borde, capas
Reusasservices.py del P3, sin tocarlo
Terminas conAPI documentada en /docs, testeada y en Docker

Objetivos

  • Exponer el inventario por HTTP: GET/POST /reactivos, POST /lotes, GET /lotes/por-vencer, POST /movimientos, GET /health.
  • Responder con el código correcto: 200, 201, 404, 409, 422. Nunca 200 {"error": …}.
  • Validar toda entrada en el borde con Pydantic, antes de que toque tu lógica.
  • Mantener las capas: router (HTTP) → services (reglas) → modelos (datos). El router no sabe de SQL.
  • Testear cada endpoint con TestClient contra una base en memoria.
  • Levantar todo con docker compose y comprobar que la base sobrevive a un reinicio.
La idea central

En el P3 la única forma de usar el inventario era estar sentada frente a tu terminal. Una API lo convierte en un servicio: cualquier programa —el tablero del P5, el vigía del P7, el agente del P10— puede pedirle cosas por la red, con un contrato escrito. Tu trabajo aquí es diseñar ese contrato y defenderlo.

Temas que vas a usar

Los de El libro de Python primero; los enlaces internos son de esta guía.

  • API con FlaskLéelo para contrastar: mismo concepto, framework distinto. Nosotros usamos FastAPI por la validación y /docs automáticos.
  • Decoradores@router.get("...") es un decorador: registra tu función como manejadora de una ruta.
  • Anotaciones de tipoEn FastAPI las anotaciones no solo documentan: definen la validación y la conversión.
  • Excepciones · Excepciones propiasStockInsuficiente del P3 se convierte en un 409 sin que services sepa qué es HTTP.
  • DiccionariosJSON entra y sale como diccionarios; los schemas les dan forma.
  • yield · Context managersLa dependencia de sesión usa yield: abrir antes del request, cerrar después.
  • Corrutinas · AsincroníaOpcional. FastAPI soporta async def; aquí usamos funciones normales y explicamos cuándo cambiar.
  • Tutorial oficial de FastAPI (en español)Muy bien escrito. Léelo en paralelo: secciones "First Steps" a "Handling Errors".
  • Los 4 hábitos · Proyecto 3Este proyecto construye encima del P3 terminado.

Antes de empezar

Necesitas el P3 terminado y mergeado. Tu repo inventario-lab debe verse así:

inventario-lab/
├── pyproject.toml            con [project.scripts] inv = "inventario.cli:app"
├── uv.lock
├── migrations/               alembic (P3)
├── src/inventario/
│   ├── __init__.py
│   ├── models.py             Ubicacion, Reactivo, Lote, Movimiento
│   ├── db.py                 engine desde INVENTARIO_DB_URL, get_session, init_db
│   ├── errors.py             InventarioError, StockInsuficiente, LoteNoExiste
│   ├── services.py           crear_reactivo, agregar_lote, registrar_salida, …
│   └── cli.py                inv add-reactivo, inv take, inv expiring, …
└── tests/                    tests del P3 (services y CLI)

Verifica que el punto de partida está sano:

uv run pytest -q
uv run ruff check .
uv run inv find taq
Si el P3 no quedó con estas firmas

Este proyecto llama a services.registrar_salida(session, codigo_lote, cantidad, nota=None) y espera que lance LoteNoExiste o StockInsuficiente. Si tus firmas difieren, primero un PR pequeño en el P3 que las alinee. No "adaptes" la API a un services desprolijo: arregla la base.

Construcción paso a paso

Igual que siempre: escribe los archivos tú misma, corre cada comando, compara con "deberías ver". Los ejemplos usan los datos del cuaderno: Taq polimerasa, el lote TAQ-2401, el Congelador -20 A.

Paso 1 Rama y dependencias

git switch -c feat/api
uv add fastapi uvicorn
uv add --dev httpx
Deberías ver
Resolved 32 packages in …
 + fastapi==0.121.x
 + starlette==…
 + uvicorn==0.52.x
 …
Quién es quién. fastapi es el framework (rutas, validación, docs). uvicorn es el servidor que escucha el puerto y le pasa los requests. httpx es un cliente HTTP: lo usan tus tests (y tú, para probar). Fíjate que uvicorn va en las dependencias principales, no en dev: el contenedor de producción lo necesita para arrancar. Ese detalle te muerde en el paso 9 si lo pones mal.

Crea la estructura de la nueva capa:

mkdir -p src/inventario/api/routers
touch src/inventario/api/__init__.py src/inventario/api/routers/__init__.py

Paso 2 El primer endpoint: /health

Empieza por el endpoint más simple posible, para ver el ciclo completo request→response antes de meter la base de datos.

src/inventario/api/main.py
"""Punto de entrada de la API. Une routers, traduce errores del dominio a HTTP."""

import os
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager

from fastapi import FastAPI

from inventario.db import init_db

VERSION = os.environ.get("INVENTARIO_VERSION", "dev")


@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
    init_db()  # antes de atender el primer request
    yield      # aquí la API atiende; lo de después de yield corre al apagarse


app = FastAPI(title="Inventario de laboratorio", version=VERSION, lifespan=lifespan)


@app.get("/health")
def health() -> dict:
    return {"status": "ok", "version": VERSION}

Levanta el servidor y déjalo corriendo en esta terminal (abre otra para los curl):

uv run uvicorn inventario.api.main:app --reload --port 8000
Deberías ver
INFO:     Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
INFO:     Started server process [12345]
INFO:     Application startup complete.

En la otra terminal, tu primer request, mirando la respuesta completa con -i (incluye status y headers):

curl -i http://localhost:8000/health
Deberías ver
HTTP/1.1 200 OK
content-type: application/json
{"status":"ok","version":"dev"}

Y abre http://localhost:8000/docs en el navegador: FastAPI genera documentación interactiva a partir de tus anotaciones. Va a crecer sola a medida que agregues endpoints.

Anatomía del request. curl mandó GET /health HTTP/1.1 a localhost:8000. El servidor respondió una línea de estado (200 OK), headers (content-type) y un cuerpo JSON. Todo HTTP es eso: método + ruta + headers + cuerpo, en las dos direcciones. --reload reinicia el servidor cuando guardas un archivo: solo para desarrollo.

Paso 3 Schemas: el contrato del borde

Los modelos del P3 son tus tablas. Los schemas son otra cosa: la forma exacta de lo que un cliente puede mandar y de lo que tu API responde. Se separan porque el contrato público no tiene por qué exponer tu esquema interno.

src/inventario/api/schemas.py
"""Esquemas de entrada y salida de la API. El contrato público, separado de las tablas."""

from datetime import date, datetime

from pydantic import BaseModel, Field


# --- entrada (lo que el cliente manda) ---


class ReactivoIn(BaseModel):
    nombre: str = Field(min_length=1)
    unidad: str = Field(min_length=1)
    cas: str | None = None


class LoteIn(BaseModel):
    reactivo_id: int
    ubicacion_id: int
    codigo: str = Field(min_length=1)
    cantidad: float = Field(gt=0)
    vencimiento: date


class MovimientoIn(BaseModel):
    codigo_lote: str = Field(min_length=1)
    cantidad: float = Field(gt=0)
    nota: str | None = None


# --- salida (lo que la API responde) ---


class ReactivoOut(BaseModel):
    id: int
    nombre: str
    unidad: str
    cas: str | None


class LoteOut(BaseModel):
    id: int
    codigo: str
    reactivo_id: int
    ubicacion_id: int
    cantidad: float
    vencimiento: date


class MovimientoOut(BaseModel):
    id: int
    lote_id: int
    cantidad: float
    fecha: datetime
    nota: str | None
Validar en el borde. cantidad: float = Field(gt=0) significa que un cliente que mande -5 recibe un 422 automático, con el nombre del campo, antes de que tu código corra. Es la misma idea del P2 (validar el CSV al entrar): adentro, tu lógica asume datos limpios. Fíjate también en qué no está: MovimientoIn no tiene fecha — la pone el servidor. Todo lo que el cliente pueda falsear, lo decide el servidor.

Paso 4 La sesión como dependencia

Cada request necesita una sesión de base de datos, y cerrarla al terminar. FastAPI resuelve eso con dependencias: funciones que se ejecutan por request y cuyo resultado se inyecta en tu endpoint.

src/inventario/api/deps.py
"""Dependencias de FastAPI. Aquí vive el 'cómo consigo una sesión'."""

from collections.abc import Iterator
from typing import Annotated

from fastapi import Depends
from sqlmodel import Session

from inventario.db import engine


def get_session() -> Iterator[Session]:
    with Session(engine) as session:
        yield session


# Alias: "una Session que FastAPI inyecta llamando a get_session".
# Los routers lo usan como tipo y no repiten Depends(...) en cada función.
SessionDep = Annotated[Session, Depends(get_session)]
Por qué el alias y no Depends en cada firma. El estilo clásico (session: Session = Depends(get_session)) funciona, pero ruff lo marca con B008 ("no llames funciones en valores por defecto") — y tiene razón en el caso general, FastAPI es la excepción. El estilo moderno con Annotated es el que recomienda la propia documentación de FastAPI: el tipo dice todo, no hay llamada en el default, y ruff queda contento. Además el yield te garantiza que la sesión se cierra aunque el endpoint lance una excepción: es un context manager repartido en dos mitades del request.

Paso 5 Router de reactivos

Un router agrupa las rutas de un recurso. Fíjate en lo que hace y en lo que no hace: traduce HTTP a llamadas a services, y nada más.

src/inventario/api/routers/reactivos.py
"""Rutas de reactivos. Traducen HTTP <-> services. Nada de SQL aquí."""

from fastapi import APIRouter, HTTPException, status

from inventario import services
from inventario.api.deps import SessionDep
from inventario.api.schemas import ReactivoIn, ReactivoOut
from inventario.models import Reactivo

router = APIRouter(prefix="/reactivos", tags=["reactivos"])


@router.get("", response_model=list[ReactivoOut])
def listar(session: SessionDep, q: str = "") -> list[Reactivo]:
    return services.buscar_reactivo(session, q)


@router.post("", response_model=ReactivoOut, status_code=status.HTTP_201_CREATED)
def crear(datos: ReactivoIn, session: SessionDep) -> Reactivo:
    return services.crear_reactivo(session, datos.nombre, datos.unidad, datos.cas)


@router.get("/{reactivo_id}", response_model=ReactivoOut)
def obtener(reactivo_id: int, session: SessionDep) -> Reactivo:
    reactivo = session.get(Reactivo, reactivo_id)
    if reactivo is None:
        raise HTTPException(status_code=404, detail=f"no existe el reactivo {reactivo_id}")
    return reactivo

Regístralo en main.py (agrega el import y la línea include_router después de crear app):

src/inventario/api/main.py (agregar)
from inventario.api.routers import reactivos

app.include_router(reactivos.router)

Pruébalo con curl (el servidor con --reload ya tomó los cambios):

curl -i -X POST http://localhost:8000/reactivos \
  -H 'Content-Type: application/json' \
  -d '{"nombre":"Taq polimerasa","unidad":"µL"}'
Deberías ver
HTTP/1.1 201 Created
{"id":1,"nombre":"Taq polimerasa","unidad":"µL","cas":null}
curl -s "http://localhost:8000/reactivos?q=taq"
curl -i http://localhost:8000/reactivos/99
curl -s -X POST http://localhost:8000/reactivos \
  -H 'Content-Type: application/json' -d '{"unidad":"mL"}'
Deberías ver (una línea por comando)
[{"id":1,"nombre":"Taq polimerasa","unidad":"µL","cas":null}]
HTTP/1.1 404 Not Found  →  {"detail":"no existe el reactivo 99"}
{"detail":[{"type":"missing","loc":["body","nombre"],"msg":"Field required","input":{"unidad":"mL"}}]}
Tres respuestas, tres significados. El 201 dice "creado" (y devuelve el recurso con su id). El 404 dice "eso no existe". El último es un 422 que generó Pydantic solo: loc: ["body", "nombre"] señala exactamente qué faltó. Tu cliente del P5 va a poder distinguir los tres casos por el código, sin adivinar leyendo mensajes.

Paso 6 Errores del dominio → códigos HTTP

El P3 ya define qué puede salir mal: LoteNoExiste, StockInsuficiente. Services no sabe qué es HTTP, y así debe seguir. La traducción vive en un solo lugar: exception handlers en main.py.

src/inventario/api/main.py (agregar)
from fastapi import Request
from fastapi.responses import JSONResponse

from inventario.errors import LoteNoExiste, StockInsuficiente


@app.exception_handler(LoteNoExiste)
def _lote_no_existe(request: Request, exc: LoteNoExiste) -> JSONResponse:
    return JSONResponse(status_code=404, content={"detail": str(exc)})


@app.exception_handler(StockInsuficiente)
def _stock_insuficiente(request: Request, exc: StockInsuficiente) -> JSONResponse:
    return JSONResponse(status_code=409, content={"detail": str(exc)})

Con eso, el router de movimientos queda de tres líneas — deja que la excepción suba:

src/inventario/api/routers/movimientos.py
"""Rutas de movimientos (entradas y salidas de stock)."""

from fastapi import APIRouter, status

from inventario import services
from inventario.api.deps import SessionDep
from inventario.api.schemas import MovimientoIn, MovimientoOut

router = APIRouter(prefix="/movimientos", tags=["movimientos"])


@router.post("", response_model=MovimientoOut, status_code=status.HTTP_201_CREATED)
def registrar_salida(datos: MovimientoIn, session: SessionDep) -> object:
    # Las excepciones del dominio (LoteNoExiste, StockInsuficiente) las
    # traducen a HTTP los exception handlers de main.py.
    return services.registrar_salida(session, datos.codigo_lote, datos.cantidad, datos.nota)
Por qué handlers y no try/except en cada router. Si cada endpoint tradujera sus propias excepciones, la regla "StockInsuficiente = 409" estaría copiada en tres lugares y algún día en dos de ellos. El handler la define una vez. Y el contrato queda simétrico: quien use services desde el CLI recibe la excepción; quien lo use por HTTP recibe el código. Misma regla, dos puertas.

Paso 7 Lotes: el 409 de otro origen

El router de lotes tiene dos casos que services no cubre: claves foráneas que no existen (404) y el código de lote duplicado, que la base rechaza con IntegrityError gracias al unique del P3 (409).

src/inventario/api/routers/lotes.py
"""Rutas de lotes."""

from fastapi import APIRouter, HTTPException, status
from sqlalchemy.exc import IntegrityError

from inventario import services
from inventario.api.deps import SessionDep
from inventario.api.schemas import LoteIn, LoteOut
from inventario.models import Reactivo, Ubicacion

router = APIRouter(prefix="/lotes", tags=["lotes"])


@router.post("", response_model=LoteOut, status_code=status.HTTP_201_CREATED)
def crear(datos: LoteIn, session: SessionDep) -> object:
    if session.get(Reactivo, datos.reactivo_id) is None:
        raise HTTPException(status_code=404, detail=f"no existe el reactivo {datos.reactivo_id}")
    if session.get(Ubicacion, datos.ubicacion_id) is None:
        raise HTTPException(status_code=404, detail=f"no existe la ubicación {datos.ubicacion_id}")
    try:
        return services.agregar_lote(
            session,
            datos.reactivo_id,
            datos.ubicacion_id,
            datos.codigo,
            datos.cantidad,
            datos.vencimiento,
        )
    except IntegrityError:
        session.rollback()
        raise HTTPException(status_code=409, detail=f"ya existe un lote {datos.codigo!r}")


@router.get("/por-vencer", response_model=list[LoteOut])
def por_vencer(session: SessionDep, dias: int = 30) -> object:
    return services.lotes_por_vencer(session, dias)

Registra los dos routers nuevos en main.py y prueba la secuencia completa. Primero la ubicación — que todavía no tiene endpoint, y eso está bien: la creas con el CLI del P3, que usa el mismo services y la misma base:

uv run inv add-ubicacion "Congelador -20 A"
curl -s -X POST http://localhost:8000/lotes -H 'Content-Type: application/json' \
  -d '{"reactivo_id":1,"ubicacion_id":1,"codigo":"TAQ-2401","cantidad":500,"vencimiento":"2026-09-01"}'
curl -i -X POST http://localhost:8000/lotes -H 'Content-Type: application/json' \
  -d '{"reactivo_id":1,"ubicacion_id":1,"codigo":"TAQ-2401","cantidad":100,"vencimiento":"2027-01-01"}'
curl -s -X POST http://localhost:8000/movimientos -H 'Content-Type: application/json' \
  -d '{"codigo_lote":"TAQ-2401","cantidad":50,"nota":"PCR del lunes"}'
curl -i -X POST http://localhost:8000/movimientos -H 'Content-Type: application/json' \
  -d '{"codigo_lote":"TAQ-2401","cantidad":9999}'
uv run inv expiring --days 60
Deberías ver (resumido)
ubicación #1: Congelador -20 A
{"id":1,"codigo":"TAQ-2401",…,"cantidad":500.0,"vencimiento":"2026-09-01"}
HTTP/1.1 409 Conflict  →  {"detail":"ya existe un lote 'TAQ-2401'"}
{"id":2,"lote_id":1,"cantidad":-50.0,"fecha":"2026-08-16T13:24:13…","nota":"PCR del lunes"}
HTTP/1.1 409 Conflict  →  {"detail":"el lote TAQ-2401 tiene 450.0, no se pueden sacar 9999.0"}
TAQ-2401    450.0    vence 2026-09-01
Mira la última línea. El CLI ve 450: la salida de 50 que hiciste por HTTP. CLI y API son dos puertas del mismo sistema porque comparten services y la base. Ese es el criterio p4a de "listo cuando", demostrado en tu terminal. Y prueba también con httpx desde Python: uv run python -c "import httpx; r = httpx.get('http://localhost:8000/health'); print(r.status_code, r.headers['content-type'])".

Paso 8 Tests: cada endpoint, cada caso

Los curls de arriba fueron exploración; los tests son los que quedan. La pieza clave es dependency_overrides: le dices a FastAPI que, en tests, get_session se reemplaza por una sesión a una base en memoria que nace y muere con cada test.

tests/conftest.py
"""Fixtures compartidas: una API contra una base en memoria, nueva en cada test."""

import pytest
from fastapi.testclient import TestClient
from sqlmodel import Session, SQLModel, StaticPool, create_engine

from inventario.api.deps import get_session
from inventario.api.main import app


@pytest.fixture()
def client():
    # Una DB sqlite en memoria por test: nace vacía, muere al terminar.
    engine = create_engine(
        "sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool
    )
    SQLModel.metadata.create_all(engine)

    def get_session_test():
        with Session(engine) as session:
            yield session

    # Le decimos a FastAPI: donde pedías get_session, usa esta otra.
    app.dependency_overrides[get_session] = get_session_test
    with TestClient(app) as c:
        yield c
    app.dependency_overrides.clear()

Y los tests. La tabla es: por endpoint, un caso feliz, uno de "no existe" y uno de entrada inválida. Una muestra (escribe el resto tú; en el repo verificado son 12):

tests/test_api.py (extracto)
def test_crear_reactivo_devuelve_201_y_el_recurso(client):
    r = client.post("/reactivos", json={"nombre": "Etanol 96%", "unidad": "mL"})
    assert r.status_code == 201
    assert r.json()["nombre"] == "Etanol 96%"


def test_crear_reactivo_sin_nombre_devuelve_422(client):
    r = client.post("/reactivos", json={"unidad": "mL"})
    assert r.status_code == 422
    assert r.json()["detail"][0]["loc"] == ["body", "nombre"]


def test_salida_mayor_al_stock_devuelve_409_y_no_descuenta(datos_base):
    r = datos_base.post("/movimientos", json={"codigo_lote": "TAQ-2401", "cantidad": 9999})
    assert r.status_code == 409
    # y el stock sigue intacto:
    lotes = datos_base.get("/lotes/por-vencer", params={"dias": 60}).json()
    assert lotes[0]["cantidad"] == 500

(datos_base es una segunda fixture que siembra Taq + Congelador + TAQ-2401 usando services; escríbela en conftest.py.) Corre todo:

uv run pytest -q
Deberías ver
............                                                             [100%]
12 passed, 1 warning in 0.07s
El test del 409 es el importante. No solo comprueba el código de estado: comprueba que el stock no cambió. Un error debe dejar el sistema como estaba (la transacción del P3 trabajando). El warning restante viene de una librería (starlette), no de tu código: leerlo, entender que no es tuyo, y seguir, también es criterio.
git add . && git commit -m "API de inventario: routers, schemas, handlers y tests"

Paso 9 Docker compose: la API como servicio

Hasta ahora la API vive en tu terminal. Compose la convierte en un servicio declarado: imagen, puerto, entorno y volumen en un archivo.

Dockerfile
FROM python:3.12-slim

COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv

WORKDIR /app

COPY pyproject.toml uv.lock ./
COPY src ./src

RUN uv sync --frozen --no-dev

# La API escucha en 0.0.0.0 para ser visible desde fuera del contenedor
CMD ["uv", "run", "uvicorn", "inventario.api.main:app", "--host", "0.0.0.0", "--port", "8000"]
compose.yaml
services:
  api:
    build: .
    ports:
      - "8000:8000"          # puerto de tu máquina : puerto del contenedor
    environment:
      INVENTARIO_DB_URL: sqlite:////data/inventario.db
      INVENTARIO_VERSION: compose-dev
    volumes:
      - datos:/data          # la DB sobrevive a docker compose down

volumes:
  datos:

Detén el uvicorn local (Ctrl+C) para liberar el puerto, levanta, y comprueba la persistencia:

docker compose up -d --build
curl -s http://localhost:8000/health
curl -s -X POST http://localhost:8000/reactivos \
  -H 'Content-Type: application/json' -d '{"nombre":"Agarosa","unidad":"g"}'
docker compose down
docker compose up -d
curl -s "http://localhost:8000/reactivos?q=agarosa"
Deberías ver
{"status":"ok","version":"compose-dev"}
{"id":1,"nombre":"Agarosa","unidad":"g","cas":null}
…
[{"id":1,"nombre":"Agarosa","unidad":"g","cas":null}]   ← sobrevivió al down/up
Dos detalles que importan. (1) --host 0.0.0.0: dentro del contenedor, "localhost" es el contenedor; si uvicorn escucha solo ahí, tu máquina no lo ve. (2) El volumen: sin datos:/data, cada down borra el inventario. Pruébalo: quita el volumen, repite la secuencia y mira desaparecer la Agarosa. Con docker compose down -v borras también el volumen — a propósito.

Paso 10 PR, y el endpoint trampa de Rodrigo

git add . && git commit -m "Dockerfile de la API y compose con volumen para la DB"
git push -u origin feat/api
gh pr create --title "API HTTP del inventario" --reviewer rotorrest \
  --body "Nueva capa api/ sobre services del P3. Endpoints de reactivos, lotes y movimientos con 201/404/409/422, tests con TestClient y compose con volumen."

Después del merge viene el ejercicio final del proyecto, al revés: Rodrigo abre un PR con un endpoint nuevo escrito mal a propósito, y tú lo revisas. Los tres defectos que va a esconder (no mires esta lista durante la revisión; úsala para corregirte después):

  • SQL en el router: una consulta armada ahí mismo en vez de llamar a services. Funciona, pero rompe las capas: la regla queda invisible para el CLI y para los tests de services.
  • 200 {"error": "..."}: el caso de fallo devuelto con código de éxito. Todo cliente que mire solo el status va a creer que funcionó.
  • Confiar en la entrada: recibir un dict crudo (sin schema) o aceptar una cantidad negativa que convierte una salida en entrada de stock.

Deja tu revisión como comentarios en el PR, con el porqué y qué harías en su lugar. Esa revisión es el examen del proyecto.

Entiende el código

La estructura final

src/inventario/
├── models.py            tablas (P3)                         ┐
├── db.py                conexión (P3)                       │ el dominio
├── errors.py            qué puede salir mal (P3)            │ no sabe de HTTP
├── services.py          las reglas (P3)                     ┘
├── cli.py               puerta 1: terminal (P3)
└── api/                 puerta 2: HTTP (P4)
    ├── main.py          app, lifespan, exception handlers
    ├── deps.py          SessionDep
    ├── schemas.py       contrato de entrada/salida
    └── routers/
        ├── reactivos.py
        ├── lotes.py
        └── movimientos.py

La flecha de dependencias apunta en un solo sentido: api → services → models. Nada del dominio importa nada de api/. Cuando en el P5 aparezca el cliente y en el P10 el MCP, serán puertas 3 y 4 sobre el mismo dominio intacto.

Los códigos que usas, con tus propios ejemplos

CódigoSignificaEn tu API
200OK, aquí está lo que pedisteGET /reactivos?q=taq devuelve la lista (aunque esté vacía: una búsqueda sin resultados no es un error)
201CreadoPOST /reactivos, POST /lotes, POST /movimientos — devuelven el recurso con su id
404Eso no existeGET /reactivos/99; salida de un lote inexistente (LoteNoExiste)
409Conflicto con el estado actualCódigo de lote duplicado; sacar 9999 de un lote con 450 (StockInsuficiente)
422Entiendo el formato, pero la entrada es inválidaFalta nombre; cantidad: -5 (lo genera Pydantic con loc del campo)
400Request malformadoJSON roto (llave sin cerrar). No lo programas: el framework lo maneja

La distinción fina que Rodrigo te va a preguntar: 404 habla de identidad (ese recurso no existe), 409 de estado (existe, pero lo que pides choca con cómo está), 422 de forma (tu entrada no cumple el contrato). Y "stateless": el servidor no recuerda nada entre requests — todo lo que importa está en la base. Por eso puedes matar el contenedor y levantarlo sin perder nada (paso 9), y por eso en el P6 se podrá escalar a varias copias.

response_model y la doble validación

response_model=ReactivoOut hace dos cosas: filtra la salida (si mañana el modelo tiene una columna interna, no se expone sola) y la valida (si tu endpoint devolviera algo sin id, el error saltaría en el servidor, no en el cliente). La entrada la valida el schema In, la salida la garantiza el schema Out: el contrato queda cerrado por los dos lados y /docs lo muestra exacto.

¿Y async?

Tus endpoints son def normales y está bien: FastAPI los corre en un pool de hilos, y SQLite es síncrono. async def vale la pena cuando el endpoint espera mucho por otros servicios (HTTP a terceros, colas). Si un día migras, el orden es: driver async de la base primero, endpoints después. Anótalo en DECISIONES.md como decisión consciente, no como omisión.

Con Claude

En este proyecto Claude ya puede escribir más — los routers de lotes y movimientos repiten el patrón del de reactivos que escribiste tú. La regla sigue: la primera pieza de cada tipo, a mano; las repeticiones, con la IA y tu lectura completa del diff. Actualiza el CLAUDE.md del repo:

CLAUDE.md (agregar a las reglas del P3)
## Reglas de la capa API
- Los routers NUNCA contienen SQL ni reglas de negocio: llaman a services.
- Errores del dominio se traducen en main.py con exception handlers, no
  con try/except en cada router.
- Todo endpoint nuevo llega con sus tres tests (feliz, no existe, inválido).
- Los códigos HTTP siguen la tabla del cuaderno: 201 crea, 404 identidad,
  409 estado, 422 forma. Prohibido responder 200 con {"error": ...}.
- Estilo de dependencias: Annotated (SessionDep), no Depends() en defaults.

Pedidos que sí valen la pena en este proyecto

Escribí el router de reactivos a mano [pegar]. Escribe el de movimientos siguiendo exactamente el mismo estilo, y dime en qué se diferencia y por qué. ¿Por qué FastAPI recomienda Annotated[Session, Depends(get_session)] en vez de Depends en el valor por defecto? ¿Qué problema ve ruff con B008? Mi test del 409 pasa pero el de 404 devuelve 500 [pegar traceback]. Creo que el exception handler no está registrado. ¿Dónde lo verificarías primero? Dame dos formas de manejar el código de lote duplicado: verificar antes con un SELECT, o capturar IntegrityError. Trade-offs de cada una (pista: condiciones de carrera). Revisa mi API como revisor de PR: capas, códigos de estado, contratos. Tres cosas que un profesional haría distinto, con el porqué.

Si algo falla

ruff marca B008 Do not perform function call `Depends` in argument defaults

Estás usando el estilo clásico session: Session = Depends(get_session). Cambia al estilo Annotated del paso 4 (el alias SessionDep). No silencies la regla con noqa: la versión con Annotated es la recomendada por FastAPI y deja a ruff activo para atrapar B008 reales en el resto del código.

Parameter without a default cannot follow a parameter with a default

Pasa al mover la sesión al estilo Annotated: def listar(q: str = "", session: SessionDep) es sintaxis inválida porque session ya no tiene default. Orden correcto: primero los parámetros sin default, después los que lo tienen — def listar(session: SessionDep, q: str = ""). A FastAPI el orden le da igual; a Python no.

El contenedor arranca pero curl falla con Connection reset / exit 56

Dos causas vistas en esta guía: (1) uvicorn quedó en el grupo dev y el Dockerfile instala con --no-devdocker compose logs te lo dice; múevelo a dependencias principales con uv remove --dev uvicorn && uv add uvicorn. (2) Hiciste curl antes de que el contenedor terminara de arrancar — mira los logs hasta ver Application startup complete y reintenta. Y si el error es address already in use al levantar compose: tu uvicorn local de desarrollo sigue vivo ocupando el 8000.

Los tests fallan con sqlite3.OperationalError: no such table

La base en memoria del test no tiene las tablas: falta SQLModel.metadata.create_all(engine) en la fixture, o el engine del test no es el que usa la app (olvidaste el dependency_overrides y la app está pegándole a tu inventario.db real — peor todavía). Verifica también poolclass=StaticPool: sin él, cada conexión sqlite en memoria es una base distinta y vacía.

GET /lotes/por-vencer devuelve 422 diciendo que "por-vencer" no es un entero

Tienes una ruta GET /lotes/{lote_id} declarada antes que /lotes/por-vencer, y FastAPI intenta encajar "por-vencer" en lote_id. Las rutas se evalúan en orden: las literales van antes que las que capturan parámetros. En este proyecto no definimos GET /lotes/{id} justamente para no pisarnos; si lo agregas, decláralo después.

Deprecation warning: on_event is deprecated, use lifespan event handlers instead

Usaste @app.on_event("startup") (lo verás en tutoriales viejos). El reemplazo es el lifespan del paso 2: un context manager async donde lo previo al yield corre al arrancar y lo posterior al apagarse. Mismo efecto, sin warning, y un solo lugar para inicialización y limpieza.

El POST funciona en /docs pero curl devuelve 422 siempre

Casi siempre es el header: sin -H 'Content-Type: application/json', el cuerpo llega como texto plano y Pydantic no lo puede leer. O comillas del shell: el JSON va entre comillas simples por fuera y dobles por dentro, exactamente como en los ejemplos. Compara tu curl carácter por carácter con el del paso 5.

Listo cuando

Las mismas casillas que en el índice; marcarlas aquí las marca allá.

La pregunta de Rodrigo en la sesión

"Un cliente hace POST /movimientos y se le corta la conexión antes de recibir la respuesta. ¿Se descontó el stock o no? ¿Cómo lo sabrías, y qué pasa si reintenta?" No hay respuesta perfecta todavía — ese problema es exactamente el tema del Proyecto 5.

Siguiente

En el Proyecto 5 · Cliente y tablero te pasas al otro lado del contrato: un cliente Python que consume esta API y un tablero para el laboratorio. Ahí descubres que la red falla, que los timeouts se eligen, y por qué reintentar un POST a ciegas es peligroso — la pregunta de arriba, convertida en código.