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 verResolved 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 verINFO: 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 verHTTP/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 verHTTP/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:
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.
DockerfileFROM 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.yamlservices:
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ódigo | Significa | En tu API |
| 200 | OK, aquí está lo que pediste | GET /reactivos?q=taq devuelve la lista (aunque esté vacía: una búsqueda sin resultados no es un error) |
| 201 | Creado | POST /reactivos, POST /lotes, POST /movimientos — devuelven el recurso con su id |
| 404 | Eso no existe | GET /reactivos/99; salida de un lote inexistente (LoteNoExiste) |
| 409 | Conflicto con el estado actual | Código de lote duplicado; sacar 9999 de un lote con 450 (StockInsuficiente) |
| 422 | Entiendo el formato, pero la entrada es inválida | Falta nombre; cantidad: -5 (lo genera Pydantic con loc del campo) |
| 400 | Request malformado | JSON 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é.